diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 58cfa6c1de..da0d780d0e 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -15,6 +15,14 @@ Two standing sections outlive any single batch and must be read on every review: ## Review batches +## 2026-09-06 — stream and cache static responses without stale HTML (partial source) + +Adopts upstream #9669, `27e6cc27fe0f3cff53a44905e40615b7db99c80c`, plus the HTML validator correction from #9799, `ce4712d5b04fb998f79fe132245289191147e5d5`, under the maintainer's standing approval. Complete source patches, PR descriptions, and relevant reviews were read. #9799 is one source commit shared with the separately adopted live-stream fix; its remaining mobile, highlighting, animation, marketing, and CI concerns are not included. The full review cursor remains unchanged. + +Static responses stream bounded chunks from one request-scoped handle, using that same handle for metadata. Immutable caching requires both a hashed asset name and membership in the Vite build manifest. Mutable assets can revalidate; HTML always returns the current shell without size/mtime validators, including SPA fallback and conditional HEAD requests. This preserves Pylon authentication, routing, compression, desktop identity, and remote origins. Hosted clients use their hosting layer; the bundled and standalone Pylon servers share this route. No mobile native dependency changes. + +Verification: the same-size/same-timestamp HTML regression failed with a stale 304 before the correction. All 174 server routing tests pass after integration, including atomic file replacement, GET/HEAD/304/cancellation handle cleanup, manifest lookalikes, authentication, and live streams. Server and web typechecks, targeted lint, formatting, and diff checks pass. Implementation branch: `upstream/2026-09-06-static-response-caching` based on `origin/pylon` at `1e7ffff246bf6ad4c31236e6b242a91d5c2275b0`. + ## 2026-09-06 — project live updates before applying buffer limits (partial source) Adopted the live-stream portion of upstream #9799, `ce4712d5b04fb998f79fe132245289191147e5d5`, under the maintainer's standing approval for compatible fixes. The complete upstream patch, PR description, and review context were read. This port changes only the live budget/coalescer, shell stream metadata, and focused WebSocket regression tests. The upstream HTML, mobile outbox, highlighting, animation, marketing, and CI changes are separate concerns and are not claimed as adopted here. The full range cursor remains unchanged. diff --git a/apps/server/src/http.ts b/apps/server/src/http.ts index e20477c582..d893f50070 100644 --- a/apps/server/src/http.ts +++ b/apps/server/src/http.ts @@ -12,6 +12,7 @@ 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 Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { cast } from "effect/Function"; import { @@ -431,10 +432,66 @@ export const attachmentUploadRouteLayer = HttpRouter.add( }), ); -export const staticAndDevRouteLayer = HttpRouter.add( - "GET", - "*", - Effect.gen(function* () { +const decodeBuildManifest = Schema.decodeUnknownEffect( + Schema.fromJsonString( + Schema.Record( + Schema.String, + Schema.Struct({ + file: Schema.String, + css: Schema.optional(Schema.Array(Schema.String)), + assets: Schema.optional(Schema.Array(Schema.String)), + }), + ), + ), +); + +const loadImmutableBuildAssets = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const staticDir = + config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined); + if (!staticDir) return new Set(); + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fileSystem.readFileString(path.join(staticDir, ".vite", "manifest.json")).pipe( + Effect.flatMap(decodeBuildManifest), + Effect.map( + (manifest) => + new Set( + Object.values(manifest).flatMap((entry) => [ + entry.file, + ...(entry.css ?? []), + ...(entry.assets ?? []), + ]), + ), + ), + Effect.orElseSucceed(() => new Set()), + ); +}); + +const openStaticFile = Effect.fn("openStaticFile")(function* (filePath: string) { + const fileSystem = yield* FileSystem.FileSystem; + // Reject directories and special files before opening. Response metadata comes from the handle. + const pathInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); + if (pathInfo?.type !== "File") return null; + const file = yield* fileSystem.open(filePath, { flag: "r" }); + const info = yield* file.stat; + return info.type === "File" ? { file, info } : null; +}); + +const streamStaticFile = (file: FileSystem.File, size: bigint) => + Stream.unfold( + 0n, + Effect.fnUntraced(function* (offset: bigint) { + if (offset >= size) return; + const remaining = size - offset; + const bytes = yield* file.readAlloc(remaining < 65_536n ? remaining : 65_536n); + if (Option.isNone(bytes)) return; + return [bytes.value, offset + BigInt(bytes.value.byteLength)] as const; + }), + ); + +const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")( + function* (immutableBuildAssets: ReadonlySet) { const request = yield* HttpServerRequest.HttpServerRequest; const url = HttpServerRequest.toURL(request); @@ -461,7 +518,6 @@ export const staticAndDevRouteLayer = HttpRouter.add( }); } - const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const staticRoot = path.resolve(staticDir); const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname; @@ -495,30 +551,78 @@ export const staticAndDevRouteLayer = HttpRouter.add( } } - const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null)); - if (!fileInfo || fileInfo.type !== "File") { - const indexPath = path.resolve(staticRoot, "index.html"); - const indexData = yield* fileSystem - .readFile(indexPath) - .pipe(Effect.orElseSucceed(() => null)); - if (!indexData) { + let opened = yield* openStaticFile(filePath); + if (!opened) { + filePath = path.resolve(staticRoot, "index.html"); + opened = yield* openStaticFile(filePath); + if (!opened) { return HttpServerResponse.text("Not Found", { status: 404 }); } - return HttpServerResponse.uint8Array(indexData, { - status: 200, - contentType: "text/html; charset=utf-8", - }); + } + const fileInfo = opened.info; + const mimeType = Mime.getType(filePath) ?? "application/octet-stream"; + const isHtml = mimeType === "text/html"; + + // A hash-like name is not enough: custom static files can use the same naming pattern. + const relativePath = path.relative(staticRoot, filePath).replaceAll("\\", "/"); + const immutable = + !isHtml && + /^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath) && + immutableBuildAssets.has(relativePath); + const headers: Record = { + "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache", + }; + // Deployments can preserve HTML size and mtime while changing its bundle URLs. + const modifiedAt = isHtml ? undefined : Option.getOrUndefined(fileInfo.mtime); + const etag = modifiedAt + ? `W/"${fileInfo.size.toString(16)}-${modifiedAt.getTime().toString(16)}"` + : undefined; + if (etag !== undefined && modifiedAt !== undefined) { + headers.ETag = etag; + headers["Last-Modified"] = modifiedAt.toUTCString(); } - const contentType = Mime.getType(filePath) ?? "application/octet-stream"; - const data = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null)); - if (!data) { - return HttpServerResponse.text("Internal Server Error", { status: 500 }); + // If-None-Match takes precedence over dates and uses weak comparison for + // GET/HEAD, including when compression changes the transferred bytes. + const ifNoneMatch = request.headers["if-none-match"]; + const ifModifiedSince = request.headers["if-modified-since"]; + const unchanged = + ifNoneMatch !== undefined + ? ifNoneMatch.split(",").some((value) => { + const candidate = value.trim(); + return ( + candidate === "*" || + (etag !== undefined && candidate.replace(/^W\//i, "") === etag.slice(2)) + ); + }) + : ifModifiedSince !== undefined && + modifiedAt !== undefined && + Date.parse(modifiedAt.toUTCString()) <= Date.parse(ifModifiedSince); + if (!isHtml && unchanged) { + return HttpServerResponse.empty({ + status: 304, + headers: { ...headers, Vary: "Accept-Encoding" }, + }); } - return HttpServerResponse.uint8Array(data, { - status: 200, + const contentType = isHtml ? "text/html; charset=utf-8" : mimeType; + // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation. + // HEAD still passes through compression, which selects headers without reading the stream. + return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), { + headers, contentType, + contentLength: Number(fileInfo.size), }); + }, + Effect.catchTags({ + PlatformError: () => + Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })), }), ); + +// Read the installed build's manifest once. Unknown files use revalidation. +export const staticAndDevRouteLayer = Layer.unwrap( + loadImmutableBuildAssets.pipe( + Effect.map((assets) => HttpRouter.add("GET", "*", handleStaticAndDevRequest(assets))), + ), +); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index abf9323803..bf43938a05 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -1628,6 +1628,350 @@ it.layer(NodeServices.layer)("server router seam", (it) => { }).pipe(Effect.provide(NodeHttpServer.layerTest)), ); + it.effect("revalidates static files without sending unchanged bodies", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-cache-" }); + const assetPath = path.join(staticDir, "app.js"); + yield* fileSystem.writeFileString(assetPath, 'export const build = "first";'); + yield* buildAppUnderTest({ config: { staticDir } }); + + const initial = yield* HttpClient.get("/app.js"); + assert.equal(initial.status, 200); + assert.equal(initial.headers["cache-control"], "no-cache"); + assert.include(yield* initial.text, "first"); + const etag = initial.headers.etag; + assert.isDefined(etag); + assert.isDefined(initial.headers["last-modified"]); + + for (const headers of [ + { "if-none-match": etag! }, + { "if-none-match": `"older", ${etag!.replace(/^W\//, "")}` }, + { "if-none-match": "*" }, + { "if-modified-since": initial.headers["last-modified"]! }, + ]) { + const response = yield* HttpClient.get("/app.js", { headers }); + assert.equal(response.status, 304); + assert.equal(response.headers.etag, etag); + assert.equal(response.headers["cache-control"], "no-cache"); + assert.equal(yield* response.text, ""); + } + + const mismatched = yield* HttpClient.get("/app.js", { + headers: { + "if-none-match": '"another-build"', + "if-modified-since": initial.headers["last-modified"]!, + }, + }); + assert.equal(mismatched.status, 200); + assert.include(yield* mismatched.text, "first"); + + yield* fileSystem.writeFileString(assetPath, 'export const build = "the next build";'); + const changed = yield* HttpClient.get("/app.js", { headers: { "if-none-match": etag! } }); + assert.equal(changed.status, 200); + assert.notEqual(changed.headers.etag, etag); + assert.include(yield* changed.text, "next build"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("serves changed HTML with the same size and timestamp", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-html-" }); + const indexPath = path.join(staticDir, "index.html"); + const modifiedAt = DateTime.toDateUtc(DateTime.makeUnsafe("1985-10-26T08:15:00.000Z")); + yield* fileSystem.writeFileString(indexPath, "old build"); + yield* fileSystem.utimes(indexPath, modifiedAt, modifiedAt); + yield* buildAppUnderTest({ config: { staticDir } }); + + const initial = yield* HttpClient.get("/"); + assert.equal(yield* initial.text, "old build"); + const previousEtag = initial.headers.etag ?? '"previous-html"'; + const nextHtml = "new build"; + yield* fileSystem.writeFileString(indexPath, nextHtml); + yield* fileSystem.utimes(indexPath, modifiedAt, modifiedAt); + + for (const [resource, headers] of [ + ["/", { "if-none-match": previousEtag }], + ["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/threads/example", { "if-modified-since": modifiedAt.toUTCString() }], + ["/", { "if-none-match": "*" }], + ] as const) { + const response = yield* HttpClient.get(resource, { headers }); + assert.equal(response.status, 200); + assert.equal(yield* response.text, nextHtml); + assert.equal(response.headers["cache-control"], "no-cache"); + assert.isUndefined(response.headers.etag); + assert.isUndefined(response.headers["last-modified"]); + } + + const head = yield* HttpClient.head("/", { + headers: { "if-none-match": previousEtag, "accept-encoding": "identity" }, + }); + assert.equal(head.status, 200); + assert.equal(head.headers["content-length"], String(Buffer.byteLength(nextHtml))); + assert.equal(yield* head.text, ""); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("caches hashed static assets without freezing mutable files or SPA fallbacks", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-hashes-" }); + yield* fileSystem.makeDirectory(path.join(staticDir, "assets")); + yield* fileSystem.makeDirectory(path.join(staticDir, ".vite")); + yield* fileSystem.writeFileString( + path.join(staticDir, ".vite", "manifest.json"), + `{ + "index.html": { "file": "assets/index-AbCd0123.js", "isEntry": true }, + "large.js": { "file": "assets/large-aBcD9876.js" } + }`, + ); + yield* fileSystem.writeFileString(path.join(staticDir, "index.html"), "app"); + yield* fileSystem.writeFileString( + path.join(staticDir, "assets", "index-AbCd0123.js"), + "export const app = true;", + ); + yield* fileSystem.writeFileString(path.join(staticDir, "assets", "config.json"), "{}"); + const largeAsset = "export const value = 123;\n".repeat(8192); + yield* fileSystem.writeFileString( + path.join(staticDir, "assets", "large-aBcD9876.js"), + largeAsset, + ); + yield* buildAppUnderTest({ config: { staticDir } }); + + const asset = yield* HttpClient.get("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/assets/index-AbCd0123.js"); + assert.equal(asset.status, 200); + assert.equal(asset.headers["cache-control"], "public, max-age=31536000, immutable"); + assert.equal(yield* asset.text, "export const app = true;"); + + const head = yield* HttpClient.head("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/assets/index-AbCd0123.js", { + headers: { "accept-encoding": "identity" }, + }); + assert.equal(head.status, 200); + assert.equal(head.headers.etag, asset.headers.etag); + assert.equal(head.headers["content-length"], String("export const app = true;".length)); + assert.equal(yield* head.text, ""); + + const compressed = yield* HttpClient.get("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/assets/large-aBcD9876.js", { + headers: { "accept-encoding": "gzip" }, + }); + assert.equal(compressed.headers["content-encoding"], "gzip"); + assert.equal(compressed.headers.vary, "Accept-Encoding"); + assert.equal(yield* compressed.text, largeAsset); + const compressedHead = yield* HttpClient.head("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/assets/large-aBcD9876.js", { + headers: { "accept-encoding": "gzip" }, + }); + assert.equal(compressedHead.status, 200); + assert.equal(compressedHead.headers["content-encoding"], "gzip"); + assert.equal(compressedHead.headers.vary, "Accept-Encoding"); + assert.equal(compressedHead.headers.etag, compressed.headers.etag); + assert.equal(compressedHead.headers["content-length"], compressed.headers["content-length"]); + assert.equal(yield* compressedHead.text, ""); + const unchanged = yield* HttpClient.get("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/assets/large-aBcD9876.js", { + headers: { "accept-encoding": "identity", "if-none-match": compressed.headers.etag! }, + }); + assert.equal(unchanged.status, 304); + assert.equal(unchanged.headers.vary, "Accept-Encoding"); + assert.equal(yield* unchanged.text, ""); + + for (const resource of [ + "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/assets/config.json", + "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/threads/example", + "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/assets/old-ZyXw9876.js", + ]) { + const response = yield* HttpClient.get(resource); + assert.equal(response.status, 200); + assert.equal(response.headers["cache-control"], "no-cache"); + assert.equal( + yield* response.text, + resource.endsWith("config.json") ? "{}" : "app", + ); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + for (const manifest of [ + { label: "missing", contents: null }, + { label: "nonmatching", contents: '{"other.js":{"file":"assets/other-AbCd0123.js"}}' }, + { label: "malformed", contents: "{not-json" }, + ]) { + it.effect(`revalidates hash-like static filenames with a ${manifest.label} manifest`, () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-static-mutable-", + }); + yield* fileSystem.makeDirectory(path.join(staticDir, "assets")); + if (manifest.contents !== null) { + yield* fileSystem.makeDirectory(path.join(staticDir, ".vite")); + yield* fileSystem.writeFileString( + path.join(staticDir, ".vite", "manifest.json"), + manifest.contents, + ); + } + const filePath = path.join(staticDir, "assets", "config-20260904.js"); + yield* fileSystem.writeFileString(filePath, "first config"); + yield* buildAppUnderTest({ config: { staticDir } }); + + const initial = yield* HttpClient.get("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/assets/config-20260904.js"); + assert.equal(initial.headers["cache-control"], "no-cache"); + assert.equal(yield* initial.text, "first config"); + + yield* fileSystem.writeFileString(filePath, "replacement config"); + const changed = yield* HttpClient.get("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/assets/config-20260904.js", { + headers: { "if-none-match": initial.headers.etag! }, + }); + assert.equal(changed.status, 200); + assert.equal(changed.headers["cache-control"], "no-cache"); + assert.notEqual(changed.headers.etag, initial.headers.etag); + assert.equal(yield* changed.text, "replacement config"); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + } + + it.effect("binds static metadata and bytes to one file across atomic replacement", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-replace-" }); + const beforeOpenPath = path.join(staticDir, "before-open.txt"); + const afterOpenPath = path.join(staticDir, "after-open.txt"); + const original = "original bytes"; + const replacement = "replacement bytes with a different size"; + for (const filePath of [beforeOpenPath, afterOpenPath]) { + yield* fileSystem.writeFileString(filePath, original); + yield* fileSystem.writeFileString(`${filePath}.next`, replacement); + } + const replaced = new Set(); + const replaceOnce = Effect.fnUntraced(function* (filePath: string) { + if (replaced.has(filePath)) return; + replaced.add(filePath); + yield* fileSystem.rename(`${filePath}.next`, filePath); + }); + const replacingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + stat: (filePath) => + fileSystem + .stat(filePath) + .pipe( + Effect.tap(() => (filePath === beforeOpenPath ? replaceOnce(filePath) : Effect.void)), + ), + open: (filePath, options) => + fileSystem + .open(filePath, options) + .pipe( + Effect.tap(() => (filePath === afterOpenPath ? replaceOnce(filePath) : Effect.void)), + ), + }); + yield* buildAppUnderTest({ config: { staticDir } }).pipe( + Effect.provideService(FileSystem.FileSystem, replacingFileSystem), + ); + + for (const [name, expected] of [ + ["before-open.txt", replacement], + ["after-open.txt", original], + ] as const) { + const response = yield* HttpClient.get(`/${name}`, { + headers: { "accept-encoding": "identity" }, + }); + assert.equal(response.status, 200); + assert.equal(response.headers["content-length"], String(expected.length)); + assert.isTrue(response.headers.etag?.startsWith(`W/"${expected.length.toString(16)}-`)); + assert.equal(yield* response.text, expected); + assert.isTrue(replaced.has(path.join(staticDir, name))); + assert.equal(yield* fileSystem.readFileString(path.join(staticDir, name)), replacement); + } + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + + it.effect("closes static file handles after GET, HEAD, 304, and request cancellation", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-close-" }); + const filePath = path.join(staticDir, "app.txt"); + const body = "file content\n".repeat(1024); + yield* fileSystem.writeFileString(filePath, body); + const closed = yield* Queue.unbounded(); + const blocked = yield* Deferred.make(); + const active = new Set(); + let blockAfterOpen = false; + let bodyReads = 0; + const trackedFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + open: (candidate, options) => + Effect.gen(function* () { + if (candidate !== filePath) return yield* fileSystem.open(candidate, options); + let opened: FileSystem.File | undefined; + // Registered first, so this signal runs after the real descriptor-close finalizer. + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + if (opened === undefined) return; + active.delete(opened); + yield* Queue.offer(closed, opened); + }), + ); + const file = yield* fileSystem.open(candidate, options); + opened = file; + active.add(file); + if (blockAfterOpen) { + yield* Deferred.succeed(blocked, undefined); + return yield* Effect.never; + } + return new Proxy(file, { + get(target, key) { + if (key === "readAlloc") { + return (size: FileSystem.SizeInput) => { + bodyReads += 1; + return target.readAlloc(size); + }; + } + return Reflect.get(target, key, target); + }, + }); + }), + }); + yield* buildAppUnderTest({ config: { staticDir } }).pipe( + Effect.provideService(FileSystem.FileSystem, trackedFileSystem), + ); + + const get = yield* HttpClient.get("/app.txt"); + assert.equal(yield* get.text, body); + yield* Queue.take(closed); + assert.equal(active.size, 0); + assert.isAbove(bodyReads, 0); + const readsAfterGet = bodyReads; + + const head = yield* HttpClient.head("/app.txt", { headers: { "accept-encoding": "gzip" } }); + assert.equal(head.status, 200); + assert.equal(head.headers["content-encoding"], "gzip"); + assert.equal(yield* head.text, ""); + yield* Queue.take(closed); + assert.equal(active.size, 0); + assert.equal(bodyReads, readsAfterGet); + + const unchanged = yield* HttpClient.get("/app.txt", { + headers: { "if-none-match": get.headers.etag! }, + }); + assert.equal(unchanged.status, 304); + yield* Queue.take(closed); + assert.equal(active.size, 0); + assert.equal(bodyReads, readsAfterGet); + + blockAfterOpen = true; + const cancelled = yield* HttpClient.get("/app.txt").pipe(Effect.forkChild); + yield* Deferred.await(blocked); + assert.equal(active.size, 1); + yield* Fiber.interrupt(cancelled); + yield* Queue.take(closed); + assert.equal(active.size, 0); + }).pipe(Effect.provide(NodeHttpServer.layerTest)), + ); + it.effect("redirects to dev URL when configured", () => Effect.gen(function* () { yield* buildAppUnderTest({ diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d2b4e9c4f6..5f8ac8d903 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -279,6 +279,7 @@ export default defineConfig(() => { build: { outDir: "dist", emptyOutDir: true, + manifest: true, sourcemap: buildSourcemap, }, test: {