| ... | @@ -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 | ); |
| 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 | 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({ |