authorgravatar for me@t3.ggTheo Browne <me@t3.gg> 2026-09-04 07:14:42-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-04 07:14:42-07:00
log27e6cc27fe0f3cff53a44905e40615b7db99c80c
treecde2789acfdb76e06db9c1d83ed97250a4f70cfb
parent8e3aa324b57dd645980b203d5f8a5b9f9dc53a84
signature Signed by PGP key B5690EEEBB952194

perf(server): cache and stream static web assets (#9669)


3 files changed, 429 insertions(+), 22 deletions(-)

apps/server/src/http.ts+124-22
...@@ -12,6 +12,7 @@ import * as FileSystem from "effect/FileSystem";...@@ -12,6 +12,7 @@ import * as FileSystem from "effect/FileSystem";
12import * as Layer from "effect/Layer";12import * as Layer from "effect/Layer";
13import * as Option from "effect/Option";13import * as Option from "effect/Option";
14import * as Path from "effect/Path";14import * as Path from "effect/Path";
15import * as Schema from "effect/Schema";
15import * as Stream from "effect/Stream";16import * as Stream from "effect/Stream";
16import { cast } from "effect/Function";17import { cast } from "effect/Function";
17import {18import {
...@@ -437,10 +438,66 @@ export const attachmentUploadRouteLayer = HttpRouter.add(...@@ -437,10 +438,66 @@ export const attachmentUploadRouteLayer = HttpRouter.add(
437 }),438 }),
438);439);
439440
440export const staticAndDevRouteLayer = HttpRouter.add(441const decodeBuildManifest = Schema.decodeUnknownEffect(
441 "GET",442 Schema.fromJsonString(
442 "*",443 Schema.Record(
443 Effect.gen(function* () {444 Schema.String,
445 Schema.Struct({
446 file: Schema.String,
447 css: Schema.optional(Schema.Array(Schema.String)),
448 assets: Schema.optional(Schema.Array(Schema.String)),
449 }),
450 ),
451 ),
452);
453
454const loadImmutableBuildAssets = Effect.gen(function* () {
455 const config = yield* ServerConfig.ServerConfig;
456 const staticDir =
457 config.staticDir ?? (config.devUrl ? yield* ServerConfig.resolveStaticDir() : undefined);
458 if (!staticDir) return new Set<string>();
459 const fileSystem = yield* FileSystem.FileSystem;
460 const path = yield* Path.Path;
461 return yield* fileSystem.readFileString(path.join(staticDir, ".vite", "manifest.json")).pipe(
462 Effect.flatMap(decodeBuildManifest),
463 Effect.map(
464 (manifest) =>
465 new Set(
466 Object.values(manifest).flatMap((entry) => [
467 entry.file,
468 ...(entry.css ?? []),
469 ...(entry.assets ?? []),
470 ]),
471 ),
472 ),
473 Effect.orElseSucceed(() => new Set<string>()),
474 );
475});
476
477const openStaticFile = Effect.fn("openStaticFile")(function* (filePath: string) {
478 const fileSystem = yield* FileSystem.FileSystem;
479 // Reject directories and special files before opening. Response metadata comes from the handle.
480 const pathInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));
481 if (pathInfo?.type !== "File") return null;
482 const file = yield* fileSystem.open(filePath, { flag: "r" });
483 const info = yield* file.stat;
484 return info.type === "File" ? { file, info } : null;
485});
486
487const streamStaticFile = (file: FileSystem.File, size: bigint) =>
488 Stream.unfold(
489 0n,
490 Effect.fnUntraced(function* (offset: bigint) {
491 if (offset >= size) return;
492 const remaining = size - offset;
493 const bytes = yield* file.readAlloc(remaining < 65_536n ? remaining : 65_536n);
494 if (Option.isNone(bytes)) return;
495 return [bytes.value, offset + BigInt(bytes.value.byteLength)] as const;
496 }),
497 );
498
499const handleStaticAndDevRequest = Effect.fn("handleStaticAndDevRequest")(
500 function* (immutableBuildAssets: ReadonlySet<string>) {
444 const request = yield* HttpServerRequest.HttpServerRequest;501 const request = yield* HttpServerRequest.HttpServerRequest;
445 const url = HttpServerRequest.toURL(request);502 const url = HttpServerRequest.toURL(request);
446503
...@@ -467,7 +524,6 @@ export const staticAndDevRouteLayer = HttpRouter.add(...@@ -467,7 +524,6 @@ export const staticAndDevRouteLayer = HttpRouter.add(
467 });524 });
468 }525 }
469526
470 const fileSystem = yield* FileSystem.FileSystem;
471 const path = yield* Path.Path;527 const path = yield* Path.Path;
472 const staticRoot = path.resolve(staticDir);528 const staticRoot = path.resolve(staticDir);
473 const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;529 const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
...@@ -501,30 +557,76 @@ export const staticAndDevRouteLayer = HttpRouter.add(...@@ -501,30 +557,76 @@ export const staticAndDevRouteLayer = HttpRouter.add(
501 }557 }
502 }558 }
503559
504 const fileInfo = yield* fileSystem.stat(filePath).pipe(Effect.orElseSucceed(() => null));560 let opened = yield* openStaticFile(filePath);
505 if (!fileInfo || fileInfo.type !== "File") {561 if (!opened) {
506 const indexPath = path.resolve(staticRoot, "index.html");562 filePath = path.resolve(staticRoot, "index.html");
507 const indexData = yield* fileSystem563 opened = yield* openStaticFile(filePath);
508 .readFile(indexPath)564 if (!opened) {
509 .pipe(Effect.orElseSucceed(() => null));
510 if (!indexData) {
511 return HttpServerResponse.text("Not Found", { status: 404 });565 return HttpServerResponse.text("Not Found", { status: 404 });
512 }566 }
513 return HttpServerResponse.uint8Array(indexData, {567 }
514 status: 200,568 const fileInfo = opened.info;
515 contentType: "text/html; charset=utf-8",569
516 });570 // A hash-like name is not enough: custom static files can use the same naming pattern.
571 const relativePath = path.relative(staticRoot, filePath).replaceAll("\\", "/");
572 const immutable =
573 /^assets\/.+-[\w-]{8}\.[^/]+$/.test(relativePath) && immutableBuildAssets.has(relativePath);
574 const headers: Record<string, string> = {
575 "Cache-Control": immutable ? "public, max-age=31536000, immutable" : "no-cache",
576 };
577 const modifiedAt = Option.getOrUndefined(fileInfo.mtime);
578 const etag = modifiedAt
579 ? `W/"${fileInfo.size.toString(16)}-${modifiedAt.getTime().toString(16)}"`
580 : undefined;
581 if (etag !== undefined && modifiedAt !== undefined) {
582 headers.ETag = etag;
583 headers["Last-Modified"] = modifiedAt.toUTCString();
517 }584 }
518585
519 const contentType = Mime.getType(filePath) ?? "application/octet-stream";586 // If-None-Match takes precedence over dates and uses weak comparison for
520 const data = yield* fileSystem.readFile(filePath).pipe(Effect.orElseSucceed(() => null));587 // GET/HEAD, including when compression changes the transferred bytes.
521 if (!data) {588 const ifNoneMatch = request.headers["if-none-match"];
522 return HttpServerResponse.text("Internal Server Error", { status: 500 });589 const ifModifiedSince = request.headers["if-modified-since"];
590 const unchanged =
591 ifNoneMatch !== undefined
592 ? ifNoneMatch.split(",").some((value) => {
593 const candidate = value.trim();
594 return (
595 candidate === "*" ||
596 (etag !== undefined && candidate.replace(/^W\//i, "") === etag.slice(2))
597 );
598 })
599 : ifModifiedSince !== undefined &&
600 modifiedAt !== undefined &&
601 Date.parse(modifiedAt.toUTCString()) <= Date.parse(ifModifiedSince);
602 if (unchanged) {
603 return HttpServerResponse.empty({
604 status: 304,
605 headers: { ...headers, Vary: "Accept-Encoding" },
606 });
523 }607 }
524608
525 return HttpServerResponse.uint8Array(data, {609 const contentType =
526 status: 200,610 path.extname(filePath) === ".html"
611 ? "text/html; charset=utf-8"
612 : (Mime.getType(filePath) ?? "application/octet-stream");
613 // The request scope closes the handle for GET, HEAD, 304, errors, and cancellation.
614 // HEAD still passes through compression, which selects headers without reading the stream.
615 return HttpServerResponse.stream(streamStaticFile(opened.file, fileInfo.size), {
616 headers,
527 contentType,617 contentType,
618 contentLength: Number(fileInfo.size),
528 });619 });
620 },
621 Effect.catchTags({
622 PlatformError: () =>
623 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
529 }),624 }),
530);625);
626
627// Read the installed build's manifest once. Unknown files use revalidation.
628export const staticAndDevRouteLayer = Layer.unwrap(
629 loadImmutableBuildAssets.pipe(
630 Effect.map((assets) => HttpRouter.add("GET", "*", handleStaticAndDevRequest(assets))),
631 ),
632);
apps/server/src/server.test.ts+304
...@@ -1647,6 +1647,310 @@ it.layer(NodeServices.layer)("server router seam", (it) => {...@@ -1647,6 +1647,310 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
1647 }).pipe(Effect.provide(NodeHttpServer.layerTest)),1647 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1648 );1648 );
16491649
1650 it.effect("revalidates static files without sending unchanged bodies", () =>
1651 Effect.gen(function* () {
1652 const fileSystem = yield* FileSystem.FileSystem;
1653 const path = yield* Path.Path;
1654 const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-cache-" });
1655 const indexPath = path.join(staticDir, "index.html");
1656 yield* fileSystem.writeFileString(indexPath, "<html>first build</html>");
1657 yield* buildAppUnderTest({ config: { staticDir } });
1658
1659 const initial = yield* HttpClient.get("/");
1660 assert.equal(initial.status, 200);
1661 assert.equal(initial.headers["cache-control"], "no-cache");
1662 assert.include(yield* initial.text, "first build");
1663 const etag = initial.headers.etag;
1664 assert.isDefined(etag);
1665 assert.isDefined(initial.headers["last-modified"]);
1666
1667 for (const headers of [
1668 { "if-none-match": etag! },
1669 { "if-none-match": `"older", ${etag!.replace(/^W\//, "")}` },
1670 { "if-none-match": "*" },
1671 { "if-modified-since": initial.headers["last-modified"]! },
1672 ]) {
1673 const response = yield* HttpClient.get("/", { headers });
1674 assert.equal(response.status, 304);
1675 assert.equal(response.headers.etag, etag);
1676 assert.equal(response.headers["cache-control"], "no-cache");
1677 assert.equal(yield* response.text, "");
1678 }
1679
1680 const mismatched = yield* HttpClient.get("/", {
1681 headers: {
1682 "if-none-match": '"another-build"',
1683 "if-modified-since": initial.headers["last-modified"]!,
1684 },
1685 });
1686 assert.equal(mismatched.status, 200);
1687 assert.include(yield* mismatched.text, "first build");
1688
1689 yield* fileSystem.writeFileString(indexPath, "<html>the next build is available</html>");
1690 const changed = yield* HttpClient.get("/", { headers: { "if-none-match": etag! } });
1691 assert.equal(changed.status, 200);
1692 assert.notEqual(changed.headers.etag, etag);
1693 assert.include(yield* changed.text, "next build");
1694 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1695 );
1696
1697 it.effect("caches hashed static assets without freezing mutable files or SPA fallbacks", () =>
1698 Effect.gen(function* () {
1699 const fileSystem = yield* FileSystem.FileSystem;
1700 const path = yield* Path.Path;
1701 const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-hashes-" });
1702 yield* fileSystem.makeDirectory(path.join(staticDir, "assets"));
1703 yield* fileSystem.makeDirectory(path.join(staticDir, ".vite"));
1704 yield* fileSystem.writeFileString(
1705 path.join(staticDir, ".vite", "manifest.json"),
1706 `{
1707 "index.html": { "file": "assets/index-AbCd0123.js", "isEntry": true },
1708 "large.js": { "file": "assets/large-aBcD9876.js" }
1709 }`,
1710 );
1711 yield* fileSystem.writeFileString(path.join(staticDir, "index.html"), "<html>app</html>");
1712 yield* fileSystem.writeFileString(
1713 path.join(staticDir, "assets", "index-AbCd0123.js"),
1714 "export const app = true;",
1715 );
1716 yield* fileSystem.writeFileString(path.join(staticDir, "assets", "config.json"), "{}");
1717 const largeAsset = "export const value = 123;\n".repeat(8192);
1718 yield* fileSystem.writeFileString(
1719 path.join(staticDir, "assets", "large-aBcD9876.js"),
1720 largeAsset,
1721 );
1722 yield* buildAppUnderTest({ config: { staticDir } });
1723
1724 const asset = yield* HttpClient.get("/assets/index-AbCd0123.js");
1725 assert.equal(asset.status, 200);
1726 assert.equal(asset.headers["cache-control"], "public, max-age=31536000, immutable");
1727 assert.equal(yield* asset.text, "export const app = true;");
1728
1729 const head = yield* HttpClient.head("/assets/index-AbCd0123.js", {
1730 headers: { "accept-encoding": "identity" },
1731 });
1732 assert.equal(head.status, 200);
1733 assert.equal(head.headers.etag, asset.headers.etag);
1734 assert.equal(head.headers["content-length"], String("export const app = true;".length));
1735 assert.equal(yield* head.text, "");
1736
1737 const compressed = yield* HttpClient.get("/assets/large-aBcD9876.js", {
1738 headers: { "accept-encoding": "gzip" },
1739 });
1740 assert.equal(compressed.headers["content-encoding"], "gzip");
1741 assert.equal(compressed.headers.vary, "Accept-Encoding");
1742 assert.equal(yield* compressed.text, largeAsset);
1743 const compressedHead = yield* HttpClient.head("/assets/large-aBcD9876.js", {
1744 headers: { "accept-encoding": "gzip" },
1745 });
1746 assert.equal(compressedHead.status, 200);
1747 assert.equal(compressedHead.headers["content-encoding"], "gzip");
1748 assert.equal(compressedHead.headers.vary, "Accept-Encoding");
1749 assert.equal(compressedHead.headers.etag, compressed.headers.etag);
1750 assert.equal(compressedHead.headers["content-length"], compressed.headers["content-length"]);
1751 assert.equal(yield* compressedHead.text, "");
1752 const unchanged = yield* HttpClient.get("/assets/large-aBcD9876.js", {
1753 headers: { "accept-encoding": "identity", "if-none-match": compressed.headers.etag! },
1754 });
1755 assert.equal(unchanged.status, 304);
1756 assert.equal(unchanged.headers.vary, "Accept-Encoding");
1757 assert.equal(yield* unchanged.text, "");
1758
1759 for (const resource of [
1760 "/assets/config.json",
1761 "/threads/example",
1762 "/assets/old-ZyXw9876.js",
1763 ]) {
1764 const response = yield* HttpClient.get(resource);
1765 assert.equal(response.status, 200);
1766 assert.equal(response.headers["cache-control"], "no-cache");
1767 assert.equal(
1768 yield* response.text,
1769 resource.endsWith("config.json") ? "{}" : "<html>app</html>",
1770 );
1771 }
1772 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1773 );
1774
1775 for (const manifest of [
1776 { label: "missing", contents: null },
1777 { label: "nonmatching", contents: '{"other.js":{"file":"assets/other-AbCd0123.js"}}' },
1778 { label: "malformed", contents: "{not-json" },
1779 ]) {
1780 it.effect(`revalidates hash-like static filenames with a ${manifest.label} manifest`, () =>
1781 Effect.gen(function* () {
1782 const fileSystem = yield* FileSystem.FileSystem;
1783 const path = yield* Path.Path;
1784 const staticDir = yield* fileSystem.makeTempDirectoryScoped({
1785 prefix: "t3-static-mutable-",
1786 });
1787 yield* fileSystem.makeDirectory(path.join(staticDir, "assets"));
1788 if (manifest.contents !== null) {
1789 yield* fileSystem.makeDirectory(path.join(staticDir, ".vite"));
1790 yield* fileSystem.writeFileString(
1791 path.join(staticDir, ".vite", "manifest.json"),
1792 manifest.contents,
1793 );
1794 }
1795 const filePath = path.join(staticDir, "assets", "config-20260904.js");
1796 yield* fileSystem.writeFileString(filePath, "first config");
1797 yield* buildAppUnderTest({ config: { staticDir } });
1798
1799 const initial = yield* HttpClient.get("/assets/config-20260904.js");
1800 assert.equal(initial.headers["cache-control"], "no-cache");
1801 assert.equal(yield* initial.text, "first config");
1802
1803 yield* fileSystem.writeFileString(filePath, "replacement config");
1804 const changed = yield* HttpClient.get("/assets/config-20260904.js", {
1805 headers: { "if-none-match": initial.headers.etag! },
1806 });
1807 assert.equal(changed.status, 200);
1808 assert.equal(changed.headers["cache-control"], "no-cache");
1809 assert.notEqual(changed.headers.etag, initial.headers.etag);
1810 assert.equal(yield* changed.text, "replacement config");
1811 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1812 );
1813 }
1814
1815 it.effect("binds static metadata and bytes to one file across atomic replacement", () =>
1816 Effect.gen(function* () {
1817 const fileSystem = yield* FileSystem.FileSystem;
1818 const path = yield* Path.Path;
1819 const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-replace-" });
1820 const beforeOpenPath = path.join(staticDir, "before-open.txt");
1821 const afterOpenPath = path.join(staticDir, "after-open.txt");
1822 const original = "original bytes";
1823 const replacement = "replacement bytes with a different size";
1824 for (const filePath of [beforeOpenPath, afterOpenPath]) {
1825 yield* fileSystem.writeFileString(filePath, original);
1826 yield* fileSystem.writeFileString(`${filePath}.next`, replacement);
1827 }
1828 const replaced = new Set<string>();
1829 const replaceOnce = Effect.fnUntraced(function* (filePath: string) {
1830 if (replaced.has(filePath)) return;
1831 replaced.add(filePath);
1832 yield* fileSystem.rename(`${filePath}.next`, filePath);
1833 });
1834 const replacingFileSystem = FileSystem.FileSystem.of({
1835 ...fileSystem,
1836 stat: (filePath) =>
1837 fileSystem
1838 .stat(filePath)
1839 .pipe(
1840 Effect.tap(() => (filePath === beforeOpenPath ? replaceOnce(filePath) : Effect.void)),
1841 ),
1842 open: (filePath, options) =>
1843 fileSystem
1844 .open(filePath, options)
1845 .pipe(
1846 Effect.tap(() => (filePath === afterOpenPath ? replaceOnce(filePath) : Effect.void)),
1847 ),
1848 });
1849 yield* buildAppUnderTest({ config: { staticDir } }).pipe(
1850 Effect.provideService(FileSystem.FileSystem, replacingFileSystem),
1851 );
1852
1853 for (const [name, expected] of [
1854 ["before-open.txt", replacement],
1855 ["after-open.txt", original],
1856 ] as const) {
1857 const response = yield* HttpClient.get(`/${name}`, {
1858 headers: { "accept-encoding": "identity" },
1859 });
1860 assert.equal(response.status, 200);
1861 assert.equal(response.headers["content-length"], String(expected.length));
1862 assert.isTrue(response.headers.etag?.startsWith(`W/"${expected.length.toString(16)}-`));
1863 assert.equal(yield* response.text, expected);
1864 assert.isTrue(replaced.has(path.join(staticDir, name)));
1865 assert.equal(yield* fileSystem.readFileString(path.join(staticDir, name)), replacement);
1866 }
1867 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1868 );
1869
1870 it.effect("closes static file handles after GET, HEAD, 304, and request cancellation", () =>
1871 Effect.gen(function* () {
1872 const fileSystem = yield* FileSystem.FileSystem;
1873 const path = yield* Path.Path;
1874 const staticDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-static-close-" });
1875 const filePath = path.join(staticDir, "index.html");
1876 const body = "<p>file content</p>".repeat(1024);
1877 yield* fileSystem.writeFileString(filePath, body);
1878 const closed = yield* Queue.unbounded<FileSystem.File>();
1879 const blocked = yield* Deferred.make<void>();
1880 const active = new Set<FileSystem.File>();
1881 let blockAfterOpen = false;
1882 let bodyReads = 0;
1883 const trackedFileSystem = FileSystem.FileSystem.of({
1884 ...fileSystem,
1885 open: (candidate, options) =>
1886 Effect.gen(function* () {
1887 if (candidate !== filePath) return yield* fileSystem.open(candidate, options);
1888 let opened: FileSystem.File | undefined;
1889 // Registered first, so this signal runs after the real descriptor-close finalizer.
1890 yield* Effect.addFinalizer(() =>
1891 Effect.gen(function* () {
1892 if (opened === undefined) return;
1893 active.delete(opened);
1894 yield* Queue.offer(closed, opened);
1895 }),
1896 );
1897 const file = yield* fileSystem.open(candidate, options);
1898 opened = file;
1899 active.add(file);
1900 if (blockAfterOpen) {
1901 yield* Deferred.succeed(blocked, undefined);
1902 return yield* Effect.never;
1903 }
1904 return new Proxy(file, {
1905 get(target, key) {
1906 if (key === "readAlloc") {
1907 return (size: FileSystem.SizeInput) => {
1908 bodyReads += 1;
1909 return target.readAlloc(size);
1910 };
1911 }
1912 return Reflect.get(target, key, target);
1913 },
1914 });
1915 }),
1916 });
1917 yield* buildAppUnderTest({ config: { staticDir } }).pipe(
1918 Effect.provideService(FileSystem.FileSystem, trackedFileSystem),
1919 );
1920
1921 const get = yield* HttpClient.get("/");
1922 assert.equal(yield* get.text, body);
1923 yield* Queue.take(closed);
1924 assert.equal(active.size, 0);
1925 assert.isAbove(bodyReads, 0);
1926 const readsAfterGet = bodyReads;
1927
1928 const head = yield* HttpClient.head("/", { headers: { "accept-encoding": "gzip" } });
1929 assert.equal(head.status, 200);
1930 assert.equal(head.headers["content-encoding"], "gzip");
1931 assert.equal(yield* head.text, "");
1932 yield* Queue.take(closed);
1933 assert.equal(active.size, 0);
1934 assert.equal(bodyReads, readsAfterGet);
1935
1936 const unchanged = yield* HttpClient.get("/", {
1937 headers: { "if-none-match": get.headers.etag! },
1938 });
1939 assert.equal(unchanged.status, 304);
1940 yield* Queue.take(closed);
1941 assert.equal(active.size, 0);
1942 assert.equal(bodyReads, readsAfterGet);
1943
1944 blockAfterOpen = true;
1945 const cancelled = yield* HttpClient.get("/").pipe(Effect.forkChild);
1946 yield* Deferred.await(blocked);
1947 assert.equal(active.size, 1);
1948 yield* Fiber.interrupt(cancelled);
1949 yield* Queue.take(closed);
1950 assert.equal(active.size, 0);
1951 }).pipe(Effect.provide(NodeHttpServer.layerTest)),
1952 );
1953
1650 it.effect("redirects to dev URL when configured", () =>1954 it.effect("redirects to dev URL when configured", () =>
1651 Effect.gen(function* () {1955 Effect.gen(function* () {
1652 yield* buildAppUnderTest({1956 yield* buildAppUnderTest({
apps/web/vite.config.ts+1
...@@ -268,6 +268,7 @@ export default defineConfig(() => {...@@ -268,6 +268,7 @@ export default defineConfig(() => {
268 build: {268 build: {
269 outDir: "dist",269 outDir: "dist",
270 emptyOutDir: true,270 emptyOutDir: true,
271 manifest: true,
271 sourcemap: buildSourcemap,272 sourcemap: buildSourcemap,
272 },273 },
273 test: {274 test: {