Merge commit '36c66dd290d3ce6eb1ccd310d0c658d4a32bb8eb' as 'repos/effect'
This commit is contained in:
1831
repos/effect/packages/platform-node/test/HttpApi.test.ts
Normal file
1831
repos/effect/packages/platform-node/test/HttpApi.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
})
|
||||
59
repos/effect/packages/platform-node/test/NodeCrypto.test.ts
Normal file
59
repos/effect/packages/platform-node/test/NodeCrypto.test.ts
Normal 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)))
|
||||
})
|
||||
157
repos/effect/packages/platform-node/test/NodeHttpClient.test.ts
Normal file
157
repos/effect/packages/platform-node/test/NodeHttpClient.test.ts
Normal 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
|
||||
})
|
||||
)
|
||||
@@ -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)))
|
||||
})
|
||||
598
repos/effect/packages/platform-node/test/NodeHttpServer.test.ts
Normal file
598
repos/effect/packages/platform-node/test/NodeHttpServer.test.ts
Normal 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)))
|
||||
})
|
||||
84
repos/effect/packages/platform-node/test/NodeRedis.test.ts
Normal file
84
repos/effect/packages/platform-node/test/NodeRedis.test.ts
Normal 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
|
||||
})
|
||||
153
repos/effect/packages/platform-node/test/NodeSocket.test.ts
Normal file
153
repos/effect/packages/platform-node/test/NodeSocket.test.ts
Normal 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"])
|
||||
}))
|
||||
})
|
||||
})
|
||||
1573
repos/effect/packages/platform-node/test/OpenApi.test.ts
Normal file
1573
repos/effect/packages/platform-node/test/OpenApi.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
292
repos/effect/packages/platform-node/test/RpcServer.test.ts
Normal file
292
repos/effect/packages/platform-node/test/RpcServer.test.ts
Normal 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 }
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
}
|
||||
`;
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
})
|
||||
@@ -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))
|
||||
@@ -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))
|
||||
1
repos/effect/packages/platform-node/test/fixtures/http-static-server-outside.txt
vendored
Normal file
1
repos/effect/packages/platform-node/test/fixtures/http-static-server-outside.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
outside root
|
||||
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/conditional.txt
vendored
Normal file
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/conditional.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
initial conditional body
|
||||
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/custom/home.html
vendored
Normal file
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/custom/home.html
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<html><body>custom home</body></html>
|
||||
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/file.binx
vendored
Normal file
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/file.binx
vendored
Normal file
@@ -0,0 +1 @@
|
||||
binary-ish
|
||||
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/guide/index.html
vendored
Normal file
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/guide/index.html
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<html><body>guide index</body></html>
|
||||
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/hello.txt
vendored
Normal file
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/hello.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
hello static file
|
||||
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/index.html
vendored
Normal file
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/index.html
vendored
Normal file
@@ -0,0 +1 @@
|
||||
<html><body>root index</body></html>
|
||||
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/range.txt
vendored
Normal file
1
repos/effect/packages/platform-node/test/fixtures/http-static-server/range.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
0123456789abcdefghijklmnopqrstuvwxyz
|
||||
64
repos/effect/packages/platform-node/test/fixtures/mysql2-utils.ts
vendored
Normal file
64
repos/effect/packages/platform-node/test/fixtures/mysql2-utils.ts
vendored
Normal 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))
|
||||
}
|
||||
39
repos/effect/packages/platform-node/test/fixtures/pg-utils.ts
vendored
Normal file
39
repos/effect/packages/platform-node/test/fixtures/pg-utils.ts
vendored
Normal 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))
|
||||
}
|
||||
149
repos/effect/packages/platform-node/test/fixtures/rpc-e2e.ts
vendored
Normal file
149
repos/effect/packages/platform-node/test/fixtures/rpc-e2e.ts
vendored
Normal 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)))
|
||||
})
|
||||
}
|
||||
175
repos/effect/packages/platform-node/test/fixtures/rpc-schemas.ts
vendored
Normal file
175
repos/effect/packages/platform-node/test/fixtures/rpc-schemas.ts
vendored
Normal 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")
|
||||
}))
|
||||
1
repos/effect/packages/platform-node/test/fixtures/text.txt
vendored
Normal file
1
repos/effect/packages/platform-node/test/fixtures/text.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
lorem ipsum dolar sit amet
|
||||
Reference in New Issue
Block a user