297 lines
9.3 KiB
TypeScript
297 lines
9.3 KiB
TypeScript
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()
|
|
}
|
|
})
|
|
})
|