| ... | ... | @@ -1647,6 +1647,310 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 1647 | 1647 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 1648 | 1648 | ); |
| 1649 | 1649 | |
| 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 | 1954 | it.effect("redirects to dev URL when configured", () => |
| 1651 | 1955 | Effect.gen(function* () { |
| 1652 | 1956 | yield* buildAppUnderTest({ |