diff --git a/bun.lock b/bun.lock index c21fb2b..680edb3 100644 --- a/bun.lock +++ b/bun.lock @@ -14,7 +14,7 @@ "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", - "esbuild": "^0.27.4", + "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.17.23", "micromatch": "^4.0.8", @@ -45,57 +45,57 @@ "@babel/runtime": ["@babel/runtime@7.29.2", "", {}, "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.4", "", { "os": "aix", "cpu": "ppc64" }, "sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.27.4", "", { "os": "android", "cpu": "arm" }, "sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.4", "", { "os": "android", "cpu": "arm64" }, "sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.28.2", "", { "os": "android", "cpu": "arm64" }, "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.27.4", "", { "os": "android", "cpu": "x64" }, "sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.28.2", "", { "os": "android", "cpu": "x64" }, "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.28.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.28.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.4", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.28.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.4", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.28.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.4", "", { "os": "linux", "cpu": "arm" }, "sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.28.2", "", { "os": "linux", "cpu": "arm" }, "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.28.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.4", "", { "os": "linux", "cpu": "ia32" }, "sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.28.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.28.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.4", "", { "os": "linux", "cpu": "none" }, "sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.28.2", "", { "os": "linux", "cpu": "none" }, "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.28.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.4", "", { "os": "linux", "cpu": "x64" }, "sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.28.2", "", { "os": "linux", "cpu": "x64" }, "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.4", "", { "os": "none", "cpu": "x64" }, "sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.28.2", "", { "os": "none", "cpu": "x64" }, "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.4", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.28.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.4", "", { "os": "openbsd", "cpu": "x64" }, "sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.28.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw=="], - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.4", "", { "os": "none", "cpu": "arm64" }, "sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg=="], + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.28.2", "", { "os": "none", "cpu": "arm64" }, "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.4", "", { "os": "sunos", "cpu": "x64" }, "sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.28.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.28.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.28.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.4", "", { "os": "win32", "cpu": "x64" }, "sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], @@ -223,7 +223,7 @@ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], - "esbuild": ["esbuild@0.27.4", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.4", "@esbuild/android-arm": "0.27.4", "@esbuild/android-arm64": "0.27.4", "@esbuild/android-x64": "0.27.4", "@esbuild/darwin-arm64": "0.27.4", "@esbuild/darwin-x64": "0.27.4", "@esbuild/freebsd-arm64": "0.27.4", "@esbuild/freebsd-x64": "0.27.4", "@esbuild/linux-arm": "0.27.4", "@esbuild/linux-arm64": "0.27.4", "@esbuild/linux-ia32": "0.27.4", "@esbuild/linux-loong64": "0.27.4", "@esbuild/linux-mips64el": "0.27.4", "@esbuild/linux-ppc64": "0.27.4", "@esbuild/linux-riscv64": "0.27.4", "@esbuild/linux-s390x": "0.27.4", "@esbuild/linux-x64": "0.27.4", "@esbuild/netbsd-arm64": "0.27.4", "@esbuild/netbsd-x64": "0.27.4", "@esbuild/openbsd-arm64": "0.27.4", "@esbuild/openbsd-x64": "0.27.4", "@esbuild/openharmony-arm64": "0.27.4", "@esbuild/sunos-x64": "0.27.4", "@esbuild/win32-arm64": "0.27.4", "@esbuild/win32-ia32": "0.27.4", "@esbuild/win32-x64": "0.27.4" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], diff --git a/dist/commands/build/index.js b/dist/commands/build/index.js index 5658ad4..1e2129a 100644 --- a/dist/commands/build/index.js +++ b/dist/commands/build/index.js @@ -22,6 +22,12 @@ export default function () { global.BUNEXT_SKIPPED_BROWSER_MODULES = new Set(); await bunextInit({ build_only: true }); // await pagesSSRBundler(); + // A failed bundle must fail the command: CI and scripts rely on + // the exit code instead of grepping logs. + if (global.BUNEXT_BUILD_FAILED) { + log.error("Build failed with errors. Exiting."); + process.exit(1); + } if (global.BUNEXT_BUNDLER_CTX_MAP) { let bundler_ctx_json_str = JSON.stringify(global.BUNEXT_BUNDLER_CTX_MAP); writeFileSync(BUNDLER_CTX_MAP_JSON, bundler_ctx_json_str, "utf-8"); diff --git a/dist/commands/dev/dev-spawn.js b/dist/commands/dev/dev-spawn.js index 3c29423..d3b9956 100644 --- a/dist/commands/dev/dev-spawn.js +++ b/dist/commands/dev/dev-spawn.js @@ -2,13 +2,23 @@ import startServer from "../../functions/server/start-server"; import { log } from "../../utils/log"; import bunextInit from "../../functions/bunext-init"; import grabDirNames from "../../utils/grab-dir-names"; +import writeErrorFile from "../../functions/write-error-file"; import { rmSync } from "fs"; const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames(); +// The supervisor only restarts the dev server when this process actually +// exits with a code. Registering a listener that merely logs (the previous +// behavior) keeps Bun from exiting: the crash is swallowed, no error file is +// written, and the supervisor never restarts the server. Log, record the real +// error, and exit non-zero so recovery actually runs. process.on("uncaughtException", (error) => { log.error(`Uncaught exception: ${error}`); + writeErrorFile({ error, exitCode: 1, force: true }); + process.exit(1); }); process.on("unhandledRejection", (reason) => { log.error(`Unhandled rejection: ${reason}`); + writeErrorFile({ error: reason, exitCode: 1, force: true }); + process.exit(1); }); log.info("Running development server ..."); try { @@ -21,5 +31,9 @@ try { await startServer(); } catch (error) { + // Startup failures (e.g. port in use, config errors) must exit non-zero; + // otherwise the process hangs alive with no server and no restart. log.error(`Failed to start development server: ${error}`); + writeErrorFile({ error, exitCode: 1, force: true }); + process.exit(1); } diff --git a/dist/commands/dev/index.js b/dist/commands/dev/index.js index 7eed3ec..8584a0a 100644 --- a/dist/commands/dev/index.js +++ b/dist/commands/dev/index.js @@ -3,7 +3,7 @@ import path from "path"; import grabDirNames from "../../utils/grab-dir-names"; import writeErrorFile from "../../functions/write-error-file"; import { existsSync, rmSync } from "fs"; -const { BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON } = grabDirNames(); +const { BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON, BUNX_BUNDLER_ERROR_EXIT_FILE, } = grabDirNames(); let retries = 0; let timeout; const MAX_RETRIES = 5; @@ -14,6 +14,10 @@ export default function () { try { rmSync(SSR_BUNDLER_CTX_MAP_JSON, { recursive: true }); rmSync(BUNDLER_CTX_MAP_JSON, { recursive: true }); + // A stale error file from a previous session makes the bundler + // run in "silent" mode and pages fail with misleading + // "Application Error" messages until the first rebuild. + rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true }); } catch (error) { } await dev(); @@ -54,7 +58,10 @@ async function dev() { return await dev(); } const exited = await dev_process.exited; - if (exited) { + // Restart on any numeric exit (0 included: the server should never exit + // cleanly). A null exit means the child was killed by a signal (e.g. + // Ctrl+C in the shared terminal), which we must not restart. + if (exited != null) { retries++; timeout = setTimeout(() => { retries = 0; diff --git a/dist/commands/start/index.js b/dist/commands/start/index.js index 2e53216..b1f9919 100644 --- a/dist/commands/start/index.js +++ b/dist/commands/start/index.js @@ -1,7 +1,9 @@ import { Command } from "commander"; import path from "path"; import writeErrorFile from "../../functions/write-error-file"; -import { existsSync } from "fs"; +import grabDirNames from "../../utils/grab-dir-names"; +import { existsSync, rmSync } from "fs"; +const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); let retries = 0; let timeout; const MAX_RETRIES = 5; @@ -9,6 +11,9 @@ export default function () { return new Command("start") .description("Start production server") .action(async () => { + // A stale error file from a previous session makes the bundler + // run in "silent" mode and pages fail with misleading messages. + rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true }); await start(); }); } @@ -47,7 +52,9 @@ async function start() { return await start(); } const exited = await dev_process.exited; - if (exited) { + // Restart on any numeric exit (0 included). A null exit means the child + // was killed by a signal, which we must not restart. + if (exited != null) { retries++; timeout = setTimeout(() => { retries = 0; diff --git a/dist/commands/start/prod-spawn.js b/dist/commands/start/prod-spawn.js index aa49658..5b15658 100644 --- a/dist/commands/start/prod-spawn.js +++ b/dist/commands/start/prod-spawn.js @@ -1,6 +1,27 @@ import bunextInit from "../../functions/bunext-init"; import startServer from "../../functions/server/start-server"; import { log } from "../../utils/log"; +import writeErrorFile from "../../functions/write-error-file"; +// Same crash contract as the dev child: log the real error, record it in the +// error file, and exit non-zero so the supervisor can restart and so the +// bundler pipeline sees the failure. +process.on("uncaughtException", (error) => { + log.error(`Uncaught exception: ${error}`); + writeErrorFile({ error, exitCode: 1, force: true }); + process.exit(1); +}); +process.on("unhandledRejection", (reason) => { + log.error(`Unhandled rejection: ${reason}`); + writeErrorFile({ error: reason, exitCode: 1, force: true }); + process.exit(1); +}); log.info("Starting production server ..."); -await bunextInit(); -await startServer(); +try { + await bunextInit(); + await startServer(); +} +catch (error) { + log.error(`Failed to start production server: ${error}`); + writeErrorFile({ error, exitCode: 1, force: true }); + process.exit(1); +} diff --git a/dist/functions/bundler/all-pages-esbuild-context-bundler.js b/dist/functions/bundler/all-pages-esbuild-context-bundler.js index 0919515..b4c8990 100644 --- a/dist/functions/bundler/all-pages-esbuild-context-bundler.js +++ b/dist/functions/bundler/all-pages-esbuild-context-bundler.js @@ -7,11 +7,12 @@ import grabClientHydrationScript from "./grab-client-hydration-script"; import path from "path"; import virtualFilesPlugin from "./plugins/virtual-files-plugin"; import esbuildCTXArtifactTracker from "./plugins/esbuild-ctx-artifact-tracker"; -import { existsSync } from "fs"; -const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE, } = grabDirNames(); +import { logBuildErrorsOnce, } from "../../utils/build-error-log"; +import { log } from "../../utils/log"; +const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames(); export default async function allPagesESBuildContextBundler(params) { + const _t0 = Date.now(); try { - const did_process_exit_because_of_bundler_error = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE); const pages = grabAllPages({ exclude_api: true }); global.BUNEXT_PAGE_FILES = pages; const dev = isDevelopment(); @@ -28,6 +29,9 @@ export default async function allPagesESBuildContextBundler(params) { entryToPage.set(entryFile, { ...page, tsx }); } const entryPoints = [...entryToPage.keys()].map((e) => `hydration-virtual:${e}`); + // This is a brand-new context: wipe the previous context's failure + // history so the "2 failed starts => dispose" guard starts fresh. + global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0; global.BUNEXT_BUNDLER_CTX = await esbuild.context({ entryPoints, outdir: HYDRATION_DST_DIR, @@ -64,14 +68,26 @@ export default async function allPagesESBuildContextBundler(params) { "react/jsx-dev-runtime", ...(global.BUNEXT_CONFIG.page_compiler_excludes || []), ], - logLevel: did_process_exit_because_of_bundler_error - ? "silent" - : undefined, + // esbuild's own error logging prints per attempt; the + // artifact-tracker plugin reports errors (deduped, once) itself. + logLevel: "silent", }); await global.BUNEXT_BUNDLER_CTX.rebuild(); } catch (error) { - console.log(`ESBUILD Error =>`, error); + global.BUNEXT_BUILD_FAILED = true; + // When the build fails with esbuild errors, the artifact-tracker's + // onEnd already reported them (deduped). "Build failed with N errors" + // is that same failure rethrown by esbuild's API — re-logging it here + // would double every incident. + if (String(error?.message || "").startsWith("Build failed with")) { + return; + } + logBuildErrorsOnce([ + { + text: `ESBUILD Error => ${error?.message || String(error)}`, + }, + ], (line) => log.error(line)); } } function forceExternalReact() { diff --git a/dist/functions/bundler/build-on-start-error-handler.js b/dist/functions/bundler/build-on-start-error-handler.js index bebe338..b5a4e3f 100644 --- a/dist/functions/bundler/build-on-start-error-handler.js +++ b/dist/functions/bundler/build-on-start-error-handler.js @@ -1,18 +1,17 @@ export default async function buildOnstartErrorHandler(params) { - // const error_msg = `Build Failed. Please check all your components and imports.`; - // log.error(error_msg); if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { return; } - // console.log(`Killing Bundler ...`); - // console.log(`global.BUNEXT_BUNDLER_CTX_DISPOSED`, global.BUNEXT_BUNDLER_CTX_DISPOSED); global.BUNEXT_BUNDLER_CTX_DISPOSED = true; global.BUNEXT_RECOMPILING = false; global.BUNEXT_IS_SERVER_COMPONENT = false; - await Promise.all([ - global.BUNEXT_SSR_BUNDLER_CTX?.dispose(), - global.BUNEXT_BUNDLER_CTX?.dispose(), - ]); - global.BUNEXT_SSR_BUNDLER_CTX = undefined; - global.BUNEXT_BUNDLER_CTX = undefined; + // NOTE: must NOT dispose the currently-building context here. This + // handler runs from the artifact-tracker plugin's onStart, i.e. while the + // context's own build is in flight. esbuild's `dispose()` waits for the + // in-flight build to finish, so disposing self = deadlock, which left the + // dev server permanently stuck after a failed rebuild. + // + // Marking the context as disposed is enough: the next rebuild attempt + // (watcher / fullRebuild) sees BUNEXT_BUNDLER_CTX_DISPOSED and recreates a + // fresh context, whose cleanup disposes the old (now idle) one. } diff --git a/dist/functions/bundler/pages-ssr-bundler.js b/dist/functions/bundler/pages-ssr-bundler.js index 66c67d9..ee51236 100644 --- a/dist/functions/bundler/pages-ssr-bundler.js +++ b/dist/functions/bundler/pages-ssr-bundler.js @@ -10,6 +10,7 @@ import ssrCTXArtifactTracker from "./plugins/ssr-ctx-artifact-tracker"; import { writeFileSync } from "fs"; import path from "path"; import { log } from "../../utils/log"; +import { logBuildErrorsOnce, } from "../../utils/build-error-log"; const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames(); export default async function pagesSSRBundler(params) { const pages = grabAllPages({ @@ -85,10 +86,16 @@ export default async function pagesSSRBundler(params) { ...(config.ssr_compiler_excludes || []), ], splitting: true, + logLevel: "silent", }); } catch (error) { global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; - log.error(`SSR Bundler Error: ${error}`); + global.BUNEXT_BUILD_FAILED = true; + logBuildErrorsOnce([ + { + text: `SSR Bundler Error: ${error?.message || String(error)}`, + }, + ], (line) => log.error(line)); } } diff --git a/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js b/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js index b928901..d59606d 100644 --- a/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js +++ b/dist/functions/bundler/plugins/esbuild-ctx-artifact-tracker.js @@ -5,6 +5,7 @@ import buildOnstartErrorHandler from "../build-on-start-error-handler"; import _ from "lodash"; import pagesSSRBundler from "../pages-ssr-bundler"; import grabDirNames from "../../../utils/grab-dir-names"; +import { logBuildErrorsOnce, resetBuildErrorLog, } from "../../../utils/build-error-log"; import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "fs"; import fullRebuild from "../../server/full-rebuild"; import path from "path"; @@ -28,12 +29,12 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, }); build.onEnd(async (result) => { if (result.errors.length > 0) { + global.BUNEXT_BUILD_FAILED = true; global.BUNEXT_RECOMPILING = false; global.BUNEXT_IS_SERVER_COMPONENT = false; - log.error(`Build errors:`); - for (const err of result.errors) { - log.error(` ${err.text}${err.location ? ` (${err.location.file}:${err.location.line}:${err.location.column})` : ""}`); - } + // Rebuild attempts repeat for every file edit / request + // while the error persists; only log the error once. + logBuildErrorsOnce(result.errors, (line) => log.error(` ${line}`)); for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) { const controller = global.BUNEXT_HMR_CONTROLLERS[i]; try { @@ -62,6 +63,13 @@ export default function esbuildCTXArtifactTracker({ entryToPage, post_build_fn, } const elapsed = (performance.now() - build_start).toFixed(0); log.success(`[Built] in ${elapsed}ms`); + // A successful build clears the "last error" so a NEW incident + // with the same signature is reported again. + resetBuildErrorLog(); + if (result.warnings.length > 0) { + logBuildErrorsOnce(result.warnings, (line) => log.warn(line)); + } + global.BUNEXT_BUILD_FAILED = false; global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0; global.BUNEXT_BUNDLER_CTX_DISPOSED = false; const does_error_file_exist = existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE); diff --git a/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.js b/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.js index d3763b7..1bb659d 100644 --- a/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.js +++ b/dist/functions/bundler/plugins/ssr-ctx-artifact-tracker.js @@ -4,6 +4,8 @@ import { writeFileSync } from "fs"; import path from "path"; import grabDirNames from "../../../utils/grab-dir-names"; import grabRelativePath from "../../../utils/grab-relative-path"; +import { logBuildErrorsOnce, resetBuildErrorLog, } from "../../../utils/build-error-log"; +import { log } from "../../../utils/log"; let build_start = 0; let build_starts = 0; const MAX_BUILD_STARTS = 2; @@ -23,6 +25,7 @@ export default function ssrCTXArtifactTracker({ entryToPage, post_build_fn, }) { }); build.onEnd(async (result) => { if (result.errors.length > 0) { + global.BUNEXT_BUILD_FAILED = true; global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; try { await global.BUNEXT_SSR_BUNDLER_CTX?.dispose(); @@ -30,9 +33,7 @@ export default function ssrCTXArtifactTracker({ entryToPage, post_build_fn, }) { catch { } global.BUNEXT_SSR_BUNDLER_CTX = undefined; build_starts = 0; - for (const err of result.errors) { - console.error(`SSR Build error: ${err.text}${err.location ? ` (${err.location.file}:${err.location.line}:${err.location.column})` : ""}`); - } + logBuildErrorsOnce(result.errors, (line) => log.error(`SSR Build error: ${line}`)); return; } const artifacts = grabArtifactsFromBundledResults({ @@ -63,6 +64,8 @@ export default function ssrCTXArtifactTracker({ entryToPage, post_build_fn, }) { } catch (error) { } global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = false; + global.BUNEXT_BUILD_FAILED = false; + resetBuildErrorLog(); }); }, }; diff --git a/dist/functions/bunext-init.d.ts b/dist/functions/bunext-init.d.ts index d1f0208..7f1fa01 100644 --- a/dist/functions/bunext-init.d.ts +++ b/dist/functions/bunext-init.d.ts @@ -44,6 +44,7 @@ declare global { var BUNEXT_SSR_BUNDLER_CTX_DISPOSED: boolean | undefined; var BUNEXT_REBUILD_RETRIES: number; var BUNEXT_IS_404_PAGE: boolean; + var BUNEXT_BUILD_FAILED: boolean; var BUNEXT_CONSTANTS: ReturnType; var BUNEXT_MAIN_CTX_BUILD_STARTS: number; } diff --git a/dist/functions/bunext-init.js b/dist/functions/bunext-init.js index ad2c0a0..e0f985d 100644 --- a/dist/functions/bunext-init.js +++ b/dist/functions/bunext-init.js @@ -40,6 +40,7 @@ export default async function bunextInit(params) { // global.BUNEXT_API_ROUTES_BUNDLER_CTX_MAP = {}; global.BUNEXT_BUNDLER_REBUILDS = 0; global.BUNEXT_REBUILD_RETRIES = 0; + global.BUNEXT_BUILD_FAILED = false; global.BUNEXT_PAGE_FILES = []; global.BUNEXT_SKIPPED_BROWSER_MODULES = new Set(); global.BUNEXT_DIR_NAMES = dirNames; diff --git a/dist/functions/grab-config.js b/dist/functions/grab-config.js index 5acb65f..9358d12 100644 --- a/dist/functions/grab-config.js +++ b/dist/functions/grab-config.js @@ -14,6 +14,10 @@ export default async function grabConfig() { return config; } catch (error) { - return undefined; + // A config that fails to load (syntax error, bad export) must not be + // silently replaced by defaults — the app would run with the wrong + // configuration and nothing would explain why. + const { CONFIG_FILE } = grabDirNames(); + exitWithError(`Failed to load config file \`${CONFIG_FILE}\`: ${error?.message || error}`); } } diff --git a/dist/functions/server/bunext-req-handler.js b/dist/functions/server/bunext-req-handler.js index 109cf12..134ec6e 100644 --- a/dist/functions/server/bunext-req-handler.js +++ b/dist/functions/server/bunext-req-handler.js @@ -7,6 +7,7 @@ import handleFiles from "./handle-files"; import handleBunextPublicAssets from "./handle-bunext-public-assets"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import { AppData } from "../../data/app-data"; +import { log } from "../../utils/log"; import fullRebuild from "./full-rebuild"; const HMR_RETRY_COOLDOWN_MS = 5000; let lastHmrRetryTime = 0; @@ -67,12 +68,12 @@ export default async function bunextRequestHandler({ req: initial_req, server, } return response; } catch (error) { + log.error(`Server Error: ${error?.message || error}\n${error?.stack || ""}`); if (is_dev) { - return new Response(`Server Error: ${error.message}`, { + return new Response(`Server Error: ${error?.message || error}`, { status: 500, }); } - console.error(`Server Error: ${error.message}`, error); return new Response("Internal Server Error", { status: 500 }); } } diff --git a/dist/functions/server/full-rebuild.js b/dist/functions/server/full-rebuild.js index 7a3664f..b2f1c7a 100644 --- a/dist/functions/server/full-rebuild.js +++ b/dist/functions/server/full-rebuild.js @@ -1,8 +1,31 @@ import { log } from "../../utils/log"; import allPagesESBuildContextBundler from "../bundler/all-pages-esbuild-context-bundler"; import serverPostBuildFn from "./server-post-build-fn"; -import watcherEsbuildCTX from "./watcher-esbuild-ctx"; +import { restartWatcher } from "./watcher-esbuild-ctx"; +import { logBuildErrorsOnce } from "../../utils/build-error-log"; +// Single-flight: error recovery paths (grab-page-component retries, the +// watcher, post-build HMR pushes) all call fullRebuild, and each rebuild +// triggers serverPostBuildFn which can re-enter error handling. Without this +// guard one broken page could fan out into cascading, overlapping rebuilds +// that leave the dev server unresponsive. Concurrent callers await the same +// in-flight rebuild instead of starting a new one. +let rebuild_in_progress; export default async function fullRebuild(params) { + if (rebuild_in_progress) { + if (params?.msg) { + log.watch(`Rebuild already in progress, skipping: ${params.msg}`); + } + return rebuild_in_progress; + } + const run = doFullRebuild(params).finally(() => { + if (rebuild_in_progress === run) { + rebuild_in_progress = undefined; + } + }); + rebuild_in_progress = run; + return run; +} +async function doFullRebuild(params) { try { const { msg } = params || {}; global.BUNEXT_RECOMPILING = true; @@ -24,14 +47,11 @@ export default async function fullRebuild(params) { }); } catch (error) { - log.error(error); + logBuildErrorsOnce([{ text: error?.message || String(error) }], (line) => log.error(line)); } finally { global.BUNEXT_RECOMPILING = false; global.BUNEXT_IS_SERVER_COMPONENT = false; } - if (global.BUNEXT_PAGES_SRC_WATCHER) { - global.BUNEXT_PAGES_SRC_WATCHER.close(); - watcherEsbuildCTX(); - } + restartWatcher(); } diff --git a/dist/functions/server/handle-routes.js b/dist/functions/server/handle-routes.js index e3527e0..4853a63 100644 --- a/dist/functions/server/handle-routes.js +++ b/dist/functions/server/handle-routes.js @@ -2,103 +2,148 @@ import grabRouteParams from "../../utils/grab-route-params"; import grabConstants from "../../utils/grab-constants"; import grabRouter from "../../utils/grab-router"; import isDevelopment from "../../utils/is-development"; +import { log } from "../../utils/log"; import _ from "lodash"; import path from "path"; import grabDirNames from "../../utils/grab-dir-names"; import grabRelativePath from "../../utils/grab-relative-path"; const { ROOT_DIR } = grabDirNames(); export default async function ({ req }) { - const url = new URL(req.url); - const is_dev = isDevelopment(); - const { MBInBytes, ServerDefaultRequestBodyLimitBytes } = grabConstants(); - const router = grabRouter(); - const match = router.match(url.pathname); - if (!match?.filePath) { - const errMsg = `Route ${url.pathname} not found`; + try { + const url = new URL(req.url); + const is_dev = isDevelopment(); + const { MBInBytes, ServerDefaultRequestBodyLimitBytes } = grabConstants(); + const router = grabRouter(); + const match = router.match(url.pathname); + if (!match?.filePath) { + const errMsg = `Route ${url.pathname} not found`; + return Response.json({ + success: false, + msg: errMsg, + }, { + status: 404, + headers: { + "Content-Type": "application/json", + }, + }); + } + const routeParams = await grabRouteParams({ + req, + query: match.query, + }); + let module; + const now = Date.now(); + const relative_match_path = grabRelativePath(match.filePath); + if (is_dev && + global.BUNEXT_SSR_BUNDLER_CTX_MAP?.[relative_match_path]?.path) { + const target_import = path.join(ROOT_DIR, global.BUNEXT_SSR_BUNDLER_CTX_MAP[relative_match_path].path); + module = await import(`${target_import}?t=${now}`); + } + else { + const import_path = is_dev + ? `${match.filePath}?t=${now}` + : match.filePath; + module = await import(import_path); + } + const config = module.config; + const maxBodyBytes = config?.max_request_body_mb + ? config.max_request_body_mb * MBInBytes + : ServerDefaultRequestBodyLimitBytes; + const contentLength = req.headers.get("content-length"); + if (contentLength) { + const size = parseInt(contentLength, 10); + if (size > maxBodyBytes) { + return Response.json({ + success: false, + msg: "Request Body Too Large!", + }, { + status: 413, + headers: { + "Content-Type": "application/json", + }, + }); + } + } + else if (req.method !== "GET" && req.method !== "HEAD") { + const body = await req.arrayBuffer(); + if (body.byteLength > maxBodyBytes) { + return Response.json({ + success: false, + msg: "Request Body Too Large!", + }, { + status: 413, + headers: { + "Content-Type": "application/json", + }, + }); + } + try { + routeParams.body = JSON.parse(new TextDecoder().decode(body) || "{}"); + } + catch (error) { + log.error(`Invalid JSON body for ${url.pathname}: ${error?.message}`); + return Response.json({ + success: false, + msg: "Invalid JSON body", + }, { + status: 400, + headers: { + "Content-Type": "application/json", + }, + }); + } + } + const target_module = (module["default"] || + module["handler"]); + let res; + try { + res = await target_module?.({ + ...routeParams, + }); + } + catch (error) { + log.error(`API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`); + return Response.json({ + success: false, + msg: `Route handler error: ${error?.message || "Internal Server Error"}`, + }, { + status: 500, + headers: { + "Content-Type": "application/json", + }, + }); + } + if (res instanceof Response) { + if (is_dev) { + res.headers.set("Cache-Control", "no-cache, no-store, must-revalidate"); + } + return res; + } + if (res) { + let final_res = Response.json(_.omit(res, [ + "bunext_api_route_res_options", + "bunext_api_route_res_transform_fn", + ]), { + ...(res.bunext_api_route_res_options || undefined), + }); + if (res.bunext_api_route_res_transform_fn) { + final_res = await res.bunext_api_route_res_transform_fn(final_res); + } + return final_res; + } + return Response.json({ err: `Route handler error` }); + } + catch (error) { + const url = new URL(req.url); + log.error(`API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`); return Response.json({ success: false, - msg: errMsg, + msg: `Route handler error: ${error?.message || "Internal Server Error"}`, }, { - status: 404, + status: 500, headers: { "Content-Type": "application/json", }, }); } - const routeParams = await grabRouteParams({ - req, - query: match.query, - }); - let module; - const now = Date.now(); - const relative_match_path = grabRelativePath(match.filePath); - if (is_dev && - global.BUNEXT_SSR_BUNDLER_CTX_MAP?.[relative_match_path]?.path) { - const target_import = path.join(ROOT_DIR, global.BUNEXT_SSR_BUNDLER_CTX_MAP[relative_match_path].path); - module = await import(`${target_import}?t=${now}`); - } - else { - const import_path = is_dev - ? `${match.filePath}?t=${now}` - : match.filePath; - module = await import(import_path); - } - const config = module.config; - const maxBodyBytes = config?.max_request_body_mb - ? config.max_request_body_mb * MBInBytes - : ServerDefaultRequestBodyLimitBytes; - const contentLength = req.headers.get("content-length"); - if (contentLength) { - const size = parseInt(contentLength, 10); - if (size > maxBodyBytes) { - return Response.json({ - success: false, - msg: "Request Body Too Large!", - }, { - status: 413, - headers: { - "Content-Type": "application/json", - }, - }); - } - } - else if (req.method !== "GET" && req.method !== "HEAD") { - const body = await req.arrayBuffer(); - if (body.byteLength > maxBodyBytes) { - return Response.json({ - success: false, - msg: "Request Body Too Large!", - }, { - status: 413, - headers: { - "Content-Type": "application/json", - }, - }); - } - routeParams.body = JSON.parse(new TextDecoder().decode(body) || "{}"); - } - const target_module = (module["default"] || - module["handler"]); - const res = await target_module?.({ - ...routeParams, - }); - if (res instanceof Response) { - if (is_dev) { - res.headers.set("Cache-Control", "no-cache, no-store, must-revalidate"); - } - return res; - } - if (res) { - let final_res = Response.json(_.omit(res, [ - "bunext_api_route_res_options", - "bunext_api_route_res_transform_fn", - ]), { - ...(res.bunext_api_route_res_options || undefined), - }); - if (res.bunext_api_route_res_transform_fn) { - final_res = await res.bunext_api_route_res_transform_fn(final_res); - } - return final_res; - } - return Response.json({ err: `Route handler error` }); } diff --git a/dist/functions/server/server-post-build-fn.js b/dist/functions/server/server-post-build-fn.js index 241c85f..baae774 100644 --- a/dist/functions/server/server-post-build-fn.js +++ b/dist/functions/server/server-post-build-fn.js @@ -43,6 +43,9 @@ export default async function serverPostBuildFn(params) { req: mock_req, return_server_res_only: true, is_hydration: true, + // This runs inside serverPostBuildFn (post-build of fullRebuild); + // letting it trigger another rebuild/retry would cascade. + no_retry: true, }); if (page_component instanceof Response) { continue; diff --git a/dist/functions/server/start-server.js b/dist/functions/server/start-server.js index 6ae8ea8..902caf3 100644 --- a/dist/functions/server/start-server.js +++ b/dist/functions/server/start-server.js @@ -2,7 +2,7 @@ import _ from "lodash"; import { log } from "../../utils/log"; import serverParamsGen from "./server-params-gen"; import isDevelopment from "../../utils/is-development"; -import watcherEsbuildCTX from "./watcher-esbuild-ctx"; +import { restartWatcher, watcherRestartAvailable, } from "./watcher-esbuild-ctx"; export default async function startServer() { const serverParams = await serverParamsGen(); const server = Bun.serve(serverParams); @@ -10,10 +10,13 @@ export default async function startServer() { global.BUNEXT_SERVER = server; log.server(`http://${server.hostname}:${server.port}`); if (is_dev) { + // Watchdog: periodically re-create the FS watcher in case it went + // stale and stopped receiving events. Skipped while a recent restart + // (e.g. by fullRebuild) is still fresh so the watcher isn't churned + // twice in a row. setInterval(() => { - if (global.BUNEXT_PAGES_SRC_WATCHER) { - global.BUNEXT_PAGES_SRC_WATCHER.close(); - watcherEsbuildCTX(); + if (watcherRestartAvailable()) { + restartWatcher(); } }, 5000); } diff --git a/dist/functions/server/watcher-esbuild-ctx.d.ts b/dist/functions/server/watcher-esbuild-ctx.d.ts index e2345ff..c146518 100644 --- a/dist/functions/server/watcher-esbuild-ctx.d.ts +++ b/dist/functions/server/watcher-esbuild-ctx.d.ts @@ -1 +1,12 @@ +/** + * Closes and re-creates the FS watcher. Deduped: callers that restart the + * watcher around the same time (fullRebuild, reloadWatcher, watchdog) only + * trigger a single re-creation. + */ +export declare function restartWatcher(): void; +/** + * True when the watcher was last (re)created long enough ago that the + * watchdog should consider re-creating it again. + */ +export declare function watcherRestartAvailable(): boolean; export default function watcherEsbuildCTX(): Promise; diff --git a/dist/functions/server/watcher-esbuild-ctx.js b/dist/functions/server/watcher-esbuild-ctx.js index c0fc7b7..a94ecad 100644 --- a/dist/functions/server/watcher-esbuild-ctx.js +++ b/dist/functions/server/watcher-esbuild-ctx.js @@ -6,7 +6,33 @@ import { AppData } from "../../data/app-data"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import pagesSSRBundler from "../bundler/pages-ssr-bundler"; import { log } from "../../utils/log"; +import { logBuildErrorsOnce } from "../../utils/build-error-log"; const { ROOT_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); +// Tracks the last watcher re-creation so the 5s watchdog in start-server +// doesn't fight fullRebuild (which also restarts the watcher after a rebuild). +let last_watcher_restart = 0; +/** + * Closes and re-creates the FS watcher. Deduped: callers that restart the + * watcher around the same time (fullRebuild, reloadWatcher, watchdog) only + * trigger a single re-creation. + */ +export function restartWatcher() { + if (!global.BUNEXT_PAGES_SRC_WATCHER) + return; + if (Date.now() - last_watcher_restart < 1000) + return; + global.BUNEXT_PAGES_SRC_WATCHER.close(); + global.BUNEXT_PAGES_SRC_WATCHER = undefined; + last_watcher_restart = Date.now(); + watcherEsbuildCTX(); +} +/** + * True when the watcher was last (re)created long enough ago that the + * watchdog should consider re-creating it again. + */ +export function watcherRestartAvailable() { + return Date.now() - last_watcher_restart > 6000; +} export default async function watcherEsbuildCTX() { const pages_src_watcher = watch(ROOT_DIR, { recursive: true, @@ -85,9 +111,16 @@ export default async function watcherEsbuildCTX() { if (filename.match(/.*\.server\.tsx?/)) { global.BUNEXT_IS_SERVER_COMPONENT = true; } - if (global.BUNEXT_BUNDLER_CTX) { + if (global.BUNEXT_BUNDLER_CTX && + !global.BUNEXT_BUNDLER_CTX_DISPOSED) { await global.BUNEXT_BUNDLER_CTX.rebuild(); } + else if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { + await fullRebuild({ + msg: `Restarting Bundler ...`, + }); + return; + } if (filename.match(/(404|500)\.tsx?/)) { for (let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; i >= 0; i--) { const controller = global.BUNEXT_HMR_CONTROLLERS[i]; @@ -129,7 +162,11 @@ export default async function watcherEsbuildCTX() { }); } catch (error) { - log.error(`Watcher rebuild failed: ${error}`); + logBuildErrorsOnce([ + { + text: `Watcher rebuild failed: ${error?.message || String(error)}`, + }, + ], (line) => log.error(line)); } finally { if (owns_recompile) { @@ -141,8 +178,5 @@ export default async function watcherEsbuildCTX() { global.BUNEXT_PAGES_SRC_WATCHER = pages_src_watcher; } function reloadWatcher() { - if (global.BUNEXT_PAGES_SRC_WATCHER) { - global.BUNEXT_PAGES_SRC_WATCHER.close(); - watcherEsbuildCTX(); - } + restartWatcher(); } diff --git a/dist/functions/server/web-pages/generate-web-html.js b/dist/functions/server/web-pages/generate-web-html.js index e1b4e76..b5d6139 100644 --- a/dist/functions/server/web-pages/generate-web-html.js +++ b/dist/functions/server/web-pages/generate-web-html.js @@ -79,11 +79,17 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa console.info = () => { }; console.debug = () => { }; let htmlBody; + let render_error; + let has_render_error = false; try { const stream = await renderToReadableStream(final_component, { onError(error) { - if (error.message.includes('unique "key" prop')) + if (error?.message?.includes('unique "key" prop')) return; + if (!has_render_error) { + has_render_error = true; + render_error = error; + } originalConsole.error(error); }, }); @@ -92,6 +98,12 @@ export default async function genWebHTML({ component: Main, pageProps, bundledMa finally { Object.assign(console, originalConsole); } + // A component crash during SSR must surface as a proper error (handled + // upstream as a 500 page). React only reports these to `onError`; without + // rethrowing, the user gets a 200 with broken/blank markup. + if (has_render_error) { + throw render_error; + } html += htmlBody; return html; } diff --git a/dist/functions/server/web-pages/grab-page-component.d.ts b/dist/functions/server/web-pages/grab-page-component.d.ts index 342f9da..db5abac 100644 --- a/dist/functions/server/web-pages/grab-page-component.d.ts +++ b/dist/functions/server/web-pages/grab-page-component.d.ts @@ -4,6 +4,12 @@ type Params = { file_path?: string; debug?: boolean; retry?: boolean; + /** + * Skip the dev auto-rebuild/retry recovery path. Used by internal callers + * (e.g. HMR post-build) where triggering yet another fullRebuild on top + * of the rebuild that's already in progress would cascade. + */ + no_retry?: boolean; return_server_res_only?: boolean; skip_server_res?: boolean; is_hydration?: boolean; diff --git a/dist/functions/server/web-pages/grab-page-component.js b/dist/functions/server/web-pages/grab-page-component.js index 654fdec..a93fe0f 100644 --- a/dist/functions/server/web-pages/grab-page-component.js +++ b/dist/functions/server/web-pages/grab-page-component.js @@ -10,6 +10,7 @@ import isDevelopment from "../../../utils/is-development"; import { existsSync } from "fs"; import grabDirNames from "../../../utils/grab-dir-names"; import grabRelativePath from "../../../utils/grab-relative-path"; +import { logBuildErrorsOnce } from "../../../utils/build-error-log"; const { BUNX_BUNDLER_ERROR_EXIT_FILE, ROOT_DIR } = grabDirNames(); class NotFoundError extends Error { status = 404; @@ -19,7 +20,7 @@ class NotFoundError extends Error { } } export default async function grabPageComponent(params) { - const { req, file_path: passed_file_path, debug, return_server_res_only, skip_server_res, is_hydration, } = params; + const { req, file_path: passed_file_path, debug, return_server_res_only, skip_server_res, is_hydration, no_retry, } = params; const url = req?.url ? new URL(req.url) : undefined; const router = global.BUNEXT_ROUTER; const is_dev = isDevelopment(); @@ -59,14 +60,20 @@ export default async function grabPageComponent(params) { } let retries = 0; const MAX_RETRIES = 2; - while (retries < MAX_RETRIES) { + // Only retry transient failures: if the last rebuild failed + // (BUNEXT_BUILD_FAILED), the file is broken and more rebuilds + // will just repeat the same error. + while (retries < MAX_RETRIES && + !global.BUNEXT_BUILD_FAILED) { await fullRebuild({ msg: `Retrying Bundle map for file \`${relative_file_path}\``, }); await Bun.sleep(1000); - bundledMap = global.BUNEXT_BUNDLER_CTX_MAP[relative_file_path]; + bundledMap = + global.BUNEXT_BUNDLER_CTX_MAP[relative_file_path]; if (bundledMap?.path) break; + retries++; } if (!bundledMap?.path) { const errMsg = `No Bundled File Path for this request path!`; @@ -116,8 +123,11 @@ export default async function grabPageComponent(params) { const is404 = error instanceof NotFoundError || error?.name === "NotFoundError" || error?.status === 404; - if (!params.retry && is_dev) { - while (global.BUNEXT_REBUILD_RETRIES < 2) { + if (!params.retry && !params.no_retry && is_dev) { + // Skip the retry cascade when the last rebuild failed: the error + // is persistent and more rebuilds would repeat it endlessly. + while (!global.BUNEXT_BUILD_FAILED && + global.BUNEXT_REBUILD_RETRIES < 2) { global.BUNEXT_REBUILD_RETRIES = global.BUNEXT_REBUILD_RETRIES + 1; await fullRebuild(); @@ -139,9 +149,12 @@ export default async function grabPageComponent(params) { global.BUNEXT_IS_404_PAGE = true; } else { - log.error(`Error Grabbing Page Component: ${error.message}`); - log.error(`Page: ${passed_file_path || url?.pathname}`); - if (is_dev) { + logBuildErrorsOnce([ + { + text: `Error Grabbing Page Component: ${error.message}; Page: ${passed_file_path || url?.pathname}`, + }, + ], (line) => log.error(line)); + if (is_dev && !params.no_retry && !global.BUNEXT_BUILD_FAILED) { fullRebuild(); } } diff --git a/dist/functions/server/web-pages/handle-web-pages.js b/dist/functions/server/web-pages/handle-web-pages.js index eea926a..2b95c3b 100644 --- a/dist/functions/server/web-pages/handle-web-pages.js +++ b/dist/functions/server/web-pages/handle-web-pages.js @@ -1,5 +1,6 @@ import isDevelopment from "../../../utils/is-development"; import { log } from "../../../utils/log"; +import { logBuildErrorsOnce } from "../../../utils/build-error-log"; import getCache from "../../cache/get-cache"; import generateWebPageResponseFromComponentReturn from "./generate-web-page-response-from-component-return"; import grabPageComponent from "./grab-page-component"; @@ -32,7 +33,11 @@ export default async function handleWebPages({ req, }) { }); } catch (error) { - log.error(`Error Handling Web Page: ${error.message}`, error); + logBuildErrorsOnce([ + { + text: `Error Handling Web Page: ${error?.message || String(error)}`, + }, + ], (line) => log.error(line)); const componentRes = await grabPageErrorComponent({ error, }); diff --git a/dist/functions/write-error-file.d.ts b/dist/functions/write-error-file.d.ts index 858488c..44e1860 100644 --- a/dist/functions/write-error-file.d.ts +++ b/dist/functions/write-error-file.d.ts @@ -1,4 +1,22 @@ -export default function writeErrorFile({ exitCode, error, }: { - error?: Bun.ErrorLike; - exitCode: number | null; -}): void; +type Params = { + /** + * Only include when the exiting process actually crashed with an error. + * Bun's spawn `onExit` `error` is only set on spawn failures, so callers + * that DO have the error (crash handlers) should pass `force: true`. + */ + force?: boolean; + error?: unknown; + exitCode?: number | null; +}; +/** + * Writes the `BUNDLER_EXIT.txt` flag that the bundler pipeline watches to + * detect that the dev/prod child process previously crashed. + * + * Guardrails: + * - Never writes for clean exits (exitCode === 0) or signal deaths where no + * error is available (Ctrl+C must not leave a stale error file behind). + * - The supervisor writes only when the file does not already exist, so the + * crash handler's richer error/stack payload is not clobbered. + */ +export default function writeErrorFile({ force, error, exitCode, }: Params): void; +export {}; diff --git a/dist/functions/write-error-file.js b/dist/functions/write-error-file.js index 5b80e15..711ef9e 100644 --- a/dist/functions/write-error-file.js +++ b/dist/functions/write-error-file.js @@ -1,21 +1,42 @@ import path from "path"; -import { mkdirSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, writeFileSync } from "fs"; import grabDirNames from "../utils/grab-dir-names"; const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); -export default function writeErrorFile({ exitCode, error, }) { +/** + * Writes the `BUNDLER_EXIT.txt` flag that the bundler pipeline watches to + * detect that the dev/prod child process previously crashed. + * + * Guardrails: + * - Never writes for clean exits (exitCode === 0) or signal deaths where no + * error is available (Ctrl+C must not leave a stale error file behind). + * - The supervisor writes only when the file does not already exist, so the + * crash handler's richer error/stack payload is not clobbered. + */ +export default function writeErrorFile({ force, error, exitCode, }) { + const has_error = error != null; + const abnormal_exit = exitCode != null && exitCode !== 0; + if (!has_error && !abnormal_exit) + return; + if (!force && existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) + return; + const err = error instanceof Error + ? error + : typeof error === "string" + ? new Error(error) + : undefined; + const message = err?.message || "Unknown error"; + const stack = err?.stack || "N/A"; let txt = ``; txt += `Bunext Error\n`; txt += `============================================\n`; - txt += `ERROR: ${error?.message}\n`; - txt += `EXIT_CODE: ${exitCode || 0}\n`; - txt += `CALL_STACK: ${error?.stack}\n`; - mkdirSync(path.dirname(BUNX_BUNDLER_ERROR_EXIT_FILE), { recursive: true }); - writeFileSync(BUNX_BUNDLER_ERROR_EXIT_FILE, txt); + txt += `ERROR: ${message}\n`; + txt += `EXIT_CODE: ${exitCode ?? "N/A"}\n`; + txt += `CALL_STACK:\n${stack}\n`; + try { + mkdirSync(path.dirname(BUNX_BUNDLER_ERROR_EXIT_FILE), { + recursive: true, + }); + writeFileSync(BUNX_BUNDLER_ERROR_EXIT_FILE, txt); + } + catch { } } -// log.info("Running development server ..."); -// try { -// rmSync(HYDRATION_DST_DIR, { recursive: true }); -// rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true }); -// } catch (error) {} -// await bunextInit(); -// await startServer(); diff --git a/dist/utils/build-error-log.d.ts b/dist/utils/build-error-log.d.ts new file mode 100644 index 0000000..d7856b6 --- /dev/null +++ b/dist/utils/build-error-log.d.ts @@ -0,0 +1,21 @@ +export type BuildErrorLike = { + text?: string; + message?: string; + id?: string; + location?: { + file?: string; + line?: number; + column?: number; + } | null; +}; +/** + * Logs each error once — identical error sets are suppressed until the + * signature changes or `resetBuildErrorLog()` is called (after a successful + * build). Returns true when something was logged. + */ +export declare function logBuildErrorsOnce(errors: BuildErrorLike[], logLine?: (line: string) => void): boolean; +/** + * Clears the dedup state so a new incident with the same signature is logged + * again. Call after a successful build. + */ +export declare function resetBuildErrorLog(): void; diff --git a/dist/utils/build-error-log.js b/dist/utils/build-error-log.js new file mode 100644 index 0000000..ecd40b1 --- /dev/null +++ b/dist/utils/build-error-log.js @@ -0,0 +1,48 @@ +// Logs build/render errors exactly once while the same error state persists. +// +// Dev recovery paths (watcher, request retries, post-build HMR) re-attempt a +// rebuild for every file edit and request while a syntax error is still in the +// file. Without deduplication that one error is printed again on every +// attempt — an endless spiral of identical log lines. The signature is +// cleared after a successful build, so the same error later (after a fix and +// regression) is logged again as a new incident. +let last_build_error_signature; +function errorSignature(errors) { + return errors + .map((e) => { + const text = e.text || e.message || e.id || ""; + const loc = e.location + ? `${e.location.file}:${e.location.line}:${e.location.column}` + : ""; + return `${text}${loc}`; + }) + .join("\n---\n"); +} +/** + * Logs each error once — identical error sets are suppressed until the + * signature changes or `resetBuildErrorLog()` is called (after a successful + * build). Returns true when something was logged. + */ +export function logBuildErrorsOnce(errors, logLine = (line) => console.error(line)) { + if (!errors || errors.length === 0) + return false; + const signature = errorSignature(errors); + if (signature === last_build_error_signature) + return false; + last_build_error_signature = signature; + for (const e of errors) { + const text = e.text || e.message || e.id || "Build Error"; + const loc = e.location + ? ` (${e.location.file}:${e.location.line}:${e.location.column})` + : ""; + logLine(`${text}${loc}`); + } + return true; +} +/** + * Clears the dedup state so a new incident with the same signature is logged + * again. Call after a successful build. + */ +export function resetBuildErrorLog() { + last_build_error_signature = undefined; +} diff --git a/package.json b/package.json index b7adcf7..5badff5 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,7 @@ "chalk": "^5.6.2", "chokidar": "^5.0.0", "commander": "^14.0.2", - "esbuild": "^0.27.4", + "esbuild": "^0.28.2", "lightningcss-wasm": "^1.32.0", "lodash": "^4.17.23", "micromatch": "^4.0.8", diff --git a/src/commands/build/index.ts b/src/commands/build/index.ts index 1ee2337..3d7e422 100644 --- a/src/commands/build/index.ts +++ b/src/commands/build/index.ts @@ -34,6 +34,13 @@ export default function () { await bunextInit({ build_only: true }); // await pagesSSRBundler(); + // A failed bundle must fail the command: CI and scripts rely on + // the exit code instead of grepping logs. + if (global.BUNEXT_BUILD_FAILED) { + log.error("Build failed with errors. Exiting."); + process.exit(1); + } + if (global.BUNEXT_BUNDLER_CTX_MAP) { let bundler_ctx_json_str = JSON.stringify( global.BUNEXT_BUNDLER_CTX_MAP, diff --git a/src/commands/dev/dev-spawn.ts b/src/commands/dev/dev-spawn.ts index 3baae27..ff9a785 100644 --- a/src/commands/dev/dev-spawn.ts +++ b/src/commands/dev/dev-spawn.ts @@ -2,16 +2,26 @@ import startServer from "../../functions/server/start-server"; import { log } from "../../utils/log"; import bunextInit from "../../functions/bunext-init"; import grabDirNames from "../../utils/grab-dir-names"; +import writeErrorFile from "../../functions/write-error-file"; import { rmSync } from "fs"; const { HYDRATION_DST_DIR, BUNX_CWD_PAGES_REWRITE_DIR } = grabDirNames(); +// The supervisor only restarts the dev server when this process actually +// exits with a code. Registering a listener that merely logs (the previous +// behavior) keeps Bun from exiting: the crash is swallowed, no error file is +// written, and the supervisor never restarts the server. Log, record the real +// error, and exit non-zero so recovery actually runs. process.on("uncaughtException", (error) => { log.error(`Uncaught exception: ${error}`); + writeErrorFile({ error, exitCode: 1, force: true }); + process.exit(1); }); process.on("unhandledRejection", (reason) => { log.error(`Unhandled rejection: ${reason}`); + writeErrorFile({ error: reason, exitCode: 1, force: true }); + process.exit(1); }); log.info("Running development server ..."); @@ -25,5 +35,9 @@ try { await bunextInit(); await startServer(); } catch (error) { + // Startup failures (e.g. port in use, config errors) must exit non-zero; + // otherwise the process hangs alive with no server and no restart. log.error(`Failed to start development server: ${error}`); + writeErrorFile({ error, exitCode: 1, force: true }); + process.exit(1); } diff --git a/src/commands/dev/index.ts b/src/commands/dev/index.ts index 480a355..6c4f2a3 100644 --- a/src/commands/dev/index.ts +++ b/src/commands/dev/index.ts @@ -5,7 +5,11 @@ import grabDirNames from "../../utils/grab-dir-names"; import writeErrorFile from "../../functions/write-error-file"; import { existsSync, rmSync } from "fs"; -const { BUNDLER_CTX_MAP_JSON, SSR_BUNDLER_CTX_MAP_JSON } = grabDirNames(); +const { + BUNDLER_CTX_MAP_JSON, + SSR_BUNDLER_CTX_MAP_JSON, + BUNX_BUNDLER_ERROR_EXIT_FILE, +} = grabDirNames(); let retries = 0; let timeout: any; @@ -18,6 +22,10 @@ export default function () { try { rmSync(SSR_BUNDLER_CTX_MAP_JSON, { recursive: true }); rmSync(BUNDLER_CTX_MAP_JSON, { recursive: true }); + // A stale error file from a previous session makes the bundler + // run in "silent" mode and pages fail with misleading + // "Application Error" messages until the first rebuild. + rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true }); } catch (error) {} await dev(); @@ -65,7 +73,10 @@ async function dev() { const exited = await dev_process.exited; - if (exited) { + // Restart on any numeric exit (0 included: the server should never exit + // cleanly). A null exit means the child was killed by a signal (e.g. + // Ctrl+C in the shared terminal), which we must not restart. + if (exited != null) { retries++; timeout = setTimeout(() => { retries = 0; diff --git a/src/commands/start/index.ts b/src/commands/start/index.ts index 221ed1a..170aad8 100644 --- a/src/commands/start/index.ts +++ b/src/commands/start/index.ts @@ -2,7 +2,10 @@ import { Command } from "commander"; import path from "path"; import type { BunSpawnOptions } from "../../types"; import writeErrorFile from "../../functions/write-error-file"; -import { existsSync } from "fs"; +import grabDirNames from "../../utils/grab-dir-names"; +import { existsSync, rmSync } from "fs"; + +const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); let retries = 0; let timeout: any; @@ -12,6 +15,10 @@ export default function () { return new Command("start") .description("Start production server") .action(async () => { + // A stale error file from a previous session makes the bundler + // run in "silent" mode and pages fail with misleading messages. + rmSync(BUNX_BUNDLER_ERROR_EXIT_FILE, { force: true }); + await start(); }); } @@ -59,7 +66,9 @@ async function start() { const exited = await dev_process.exited; - if (exited) { + // Restart on any numeric exit (0 included). A null exit means the child + // was killed by a signal, which we must not restart. + if (exited != null) { retries++; timeout = setTimeout(() => { retries = 0; diff --git a/src/commands/start/prod-spawn.ts b/src/commands/start/prod-spawn.ts index fa9dd35..2a9ae1e 100644 --- a/src/commands/start/prod-spawn.ts +++ b/src/commands/start/prod-spawn.ts @@ -1,9 +1,30 @@ import bunextInit from "../../functions/bunext-init"; import startServer from "../../functions/server/start-server"; import { log } from "../../utils/log"; +import writeErrorFile from "../../functions/write-error-file"; + +// Same crash contract as the dev child: log the real error, record it in the +// error file, and exit non-zero so the supervisor can restart and so the +// bundler pipeline sees the failure. +process.on("uncaughtException", (error) => { + log.error(`Uncaught exception: ${error}`); + writeErrorFile({ error, exitCode: 1, force: true }); + process.exit(1); +}); + +process.on("unhandledRejection", (reason) => { + log.error(`Unhandled rejection: ${reason}`); + writeErrorFile({ error: reason, exitCode: 1, force: true }); + process.exit(1); +}); log.info("Starting production server ..."); -await bunextInit(); - -await startServer(); +try { + await bunextInit(); + await startServer(); +} catch (error) { + log.error(`Failed to start production server: ${error}`); + writeErrorFile({ error, exitCode: 1, force: true }); + process.exit(1); +} diff --git a/src/functions/bundler/all-pages-esbuild-context-bundler.ts b/src/functions/bundler/all-pages-esbuild-context-bundler.ts index abbb0a2..f126815 100644 --- a/src/functions/bundler/all-pages-esbuild-context-bundler.ts +++ b/src/functions/bundler/all-pages-esbuild-context-bundler.ts @@ -8,13 +8,12 @@ import type { BundlerCTXMap, PageFiles } from "../../types"; import path from "path"; import virtualFilesPlugin from "./plugins/virtual-files-plugin"; import esbuildCTXArtifactTracker from "./plugins/esbuild-ctx-artifact-tracker"; -import { existsSync } from "fs"; +import { + logBuildErrorsOnce, +} from "../../utils/build-error-log"; +import { log } from "../../utils/log"; -const { - HYDRATION_DST_DIR, - BUNX_HYDRATION_SRC_DIR, - BUNX_BUNDLER_ERROR_EXIT_FILE, -} = grabDirNames(); +const { HYDRATION_DST_DIR, BUNX_HYDRATION_SRC_DIR } = grabDirNames(); type Params = { post_build_fn?: (params: { @@ -25,11 +24,8 @@ type Params = { }; export default async function allPagesESBuildContextBundler(params?: Params) { + const _t0 = Date.now(); try { - const did_process_exit_because_of_bundler_error = existsSync( - BUNX_BUNDLER_ERROR_EXIT_FILE, - ); - const pages = grabAllPages({ exclude_api: true }); global.BUNEXT_PAGE_FILES = pages; @@ -60,6 +56,10 @@ export default async function allPagesESBuildContextBundler(params?: Params) { (e) => `hydration-virtual:${e}`, ); + // This is a brand-new context: wipe the previous context's failure + // history so the "2 failed starts => dispose" guard starts fresh. + global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0; + global.BUNEXT_BUNDLER_CTX = await esbuild.context({ entryPoints, outdir: HYDRATION_DST_DIR, @@ -98,14 +98,33 @@ export default async function allPagesESBuildContextBundler(params?: Params) { "react/jsx-dev-runtime", ...(global.BUNEXT_CONFIG.page_compiler_excludes || []), ], - logLevel: did_process_exit_because_of_bundler_error - ? "silent" - : undefined, + // esbuild's own error logging prints per attempt; the + // artifact-tracker plugin reports errors (deduped, once) itself. + logLevel: "silent", }); await global.BUNEXT_BUNDLER_CTX.rebuild(); - } catch (error) { - console.log(`ESBUILD Error =>`, error); + } catch (error: any) { + global.BUNEXT_BUILD_FAILED = true; + + // When the build fails with esbuild errors, the artifact-tracker's + // onEnd already reported them (deduped). "Build failed with N errors" + // is that same failure rethrown by esbuild's API — re-logging it here + // would double every incident. + if (String(error?.message || "").startsWith("Build failed with")) { + return; + } + + logBuildErrorsOnce( + [ + { + text: `ESBUILD Error => ${ + error?.message || String(error) + }`, + }, + ], + (line) => log.error(line), + ); } } diff --git a/src/functions/bundler/build-on-start-error-handler.ts b/src/functions/bundler/build-on-start-error-handler.ts index ab2c557..7d29f4b 100644 --- a/src/functions/bundler/build-on-start-error-handler.ts +++ b/src/functions/bundler/build-on-start-error-handler.ts @@ -1,26 +1,22 @@ type Params = {}; export default async function buildOnstartErrorHandler(params?: Params) { - // const error_msg = `Build Failed. Please check all your components and imports.`; - // log.error(error_msg); - if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { return; } - // console.log(`Killing Bundler ...`); - // console.log(`global.BUNEXT_BUNDLER_CTX_DISPOSED`, global.BUNEXT_BUNDLER_CTX_DISPOSED); - global.BUNEXT_BUNDLER_CTX_DISPOSED = true; global.BUNEXT_RECOMPILING = false; global.BUNEXT_IS_SERVER_COMPONENT = false; - await Promise.all([ - global.BUNEXT_SSR_BUNDLER_CTX?.dispose(), - global.BUNEXT_BUNDLER_CTX?.dispose(), - ]); - - global.BUNEXT_SSR_BUNDLER_CTX = undefined; - global.BUNEXT_BUNDLER_CTX = undefined; + // NOTE: must NOT dispose the currently-building context here. This + // handler runs from the artifact-tracker plugin's onStart, i.e. while the + // context's own build is in flight. esbuild's `dispose()` waits for the + // in-flight build to finish, so disposing self = deadlock, which left the + // dev server permanently stuck after a failed rebuild. + // + // Marking the context as disposed is enough: the next rebuild attempt + // (watcher / fullRebuild) sees BUNEXT_BUNDLER_CTX_DISPOSED and recreates a + // fresh context, whose cleanup disposes the old (now idle) one. } diff --git a/src/functions/bundler/pages-ssr-bundler.ts b/src/functions/bundler/pages-ssr-bundler.ts index 65acea3..e1e60f6 100644 --- a/src/functions/bundler/pages-ssr-bundler.ts +++ b/src/functions/bundler/pages-ssr-bundler.ts @@ -11,6 +11,9 @@ import ssrCTXArtifactTracker from "./plugins/ssr-ctx-artifact-tracker"; import { writeFileSync } from "fs"; import path from "path"; import { log } from "../../utils/log"; +import { + logBuildErrorsOnce, +} from "../../utils/build-error-log"; const { BUNX_CWD_MODULE_CACHE_DIR, BUNX_TMP_DIR } = grabDirNames(); @@ -114,9 +117,20 @@ export default async function pagesSSRBundler(params?: Params) { ...(config.ssr_compiler_excludes || []), ], splitting: true, + logLevel: "silent", }); - } catch (error) { + } catch (error: any) { global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; - log.error(`SSR Bundler Error: ${error}`); + global.BUNEXT_BUILD_FAILED = true; + logBuildErrorsOnce( + [ + { + text: `SSR Bundler Error: ${ + error?.message || String(error) + }`, + }, + ], + (line) => log.error(line), + ); } } diff --git a/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts b/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts index ae699ef..54333a4 100644 --- a/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts +++ b/src/functions/bundler/plugins/esbuild-ctx-artifact-tracker.ts @@ -6,6 +6,10 @@ import buildOnstartErrorHandler from "../build-on-start-error-handler"; import _ from "lodash"; import pagesSSRBundler from "../pages-ssr-bundler"; import grabDirNames from "../../../utils/grab-dir-names"; +import { + logBuildErrorsOnce, + resetBuildErrorLog, +} from "../../../utils/build-error-log"; import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from "fs"; import fullRebuild from "../../server/full-rebuild"; import path from "path"; @@ -55,15 +59,15 @@ export default function esbuildCTXArtifactTracker({ build.onEnd(async (result) => { if (result.errors.length > 0) { + global.BUNEXT_BUILD_FAILED = true; global.BUNEXT_RECOMPILING = false; global.BUNEXT_IS_SERVER_COMPONENT = false; - log.error(`Build errors:`); - for (const err of result.errors) { - log.error( - ` ${err.text}${err.location ? ` (${err.location.file}:${err.location.line}:${err.location.column})` : ""}`, - ); - } + // Rebuild attempts repeat for every file edit / request + // while the error persists; only log the error once. + logBuildErrorsOnce(result.errors, (line) => + log.error(` ${line}`), + ); for ( let i = global.BUNEXT_HMR_CONTROLLERS.length - 1; @@ -114,6 +118,17 @@ export default function esbuildCTXArtifactTracker({ const elapsed = (performance.now() - build_start).toFixed(0); log.success(`[Built] in ${elapsed}ms`); + // A successful build clears the "last error" so a NEW incident + // with the same signature is reported again. + resetBuildErrorLog(); + + if (result.warnings.length > 0) { + logBuildErrorsOnce(result.warnings, (line) => + log.warn(line), + ); + } + + global.BUNEXT_BUILD_FAILED = false; global.BUNEXT_MAIN_CTX_BUILD_STARTS = 0; global.BUNEXT_BUNDLER_CTX_DISPOSED = false; diff --git a/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts b/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts index cd93a7c..fa72b39 100644 --- a/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts +++ b/src/functions/bundler/plugins/ssr-ctx-artifact-tracker.ts @@ -5,6 +5,11 @@ import { writeFileSync } from "fs"; import path from "path"; import grabDirNames from "../../../utils/grab-dir-names"; import grabRelativePath from "../../../utils/grab-relative-path"; +import { + logBuildErrorsOnce, + resetBuildErrorLog, +} from "../../../utils/build-error-log"; +import { log } from "../../../utils/log"; let build_start = 0; let build_starts = 0; @@ -41,17 +46,16 @@ export default function ssrCTXArtifactTracker({ build.onEnd(async (result) => { if (result.errors.length > 0) { + global.BUNEXT_BUILD_FAILED = true; global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = true; try { await global.BUNEXT_SSR_BUNDLER_CTX?.dispose(); } catch {} global.BUNEXT_SSR_BUNDLER_CTX = undefined; build_starts = 0; - for (const err of result.errors) { - console.error( - `SSR Build error: ${err.text}${err.location ? ` (${err.location.file}:${err.location.line}:${err.location.column})` : ""}`, - ); - } + logBuildErrorsOnce(result.errors, (line) => + log.error(`SSR Build error: ${line}`), + ); return; } @@ -100,6 +104,8 @@ export default function ssrCTXArtifactTracker({ } catch (error) {} global.BUNEXT_SSR_BUNDLER_CTX_DISPOSED = false; + global.BUNEXT_BUILD_FAILED = false; + resetBuildErrorLog(); }); }, }; diff --git a/src/functions/bunext-init.ts b/src/functions/bunext-init.ts index f848ac5..ff9f1d5 100644 --- a/src/functions/bunext-init.ts +++ b/src/functions/bunext-init.ts @@ -51,6 +51,7 @@ declare global { var BUNEXT_SSR_BUNDLER_CTX_DISPOSED: boolean | undefined; var BUNEXT_REBUILD_RETRIES: number; var BUNEXT_IS_404_PAGE: boolean; + var BUNEXT_BUILD_FAILED: boolean; var BUNEXT_CONSTANTS: ReturnType; var BUNEXT_MAIN_CTX_BUILD_STARTS: number; } @@ -101,6 +102,7 @@ export default async function bunextInit(params?: Params) { // global.BUNEXT_API_ROUTES_BUNDLER_CTX_MAP = {}; global.BUNEXT_BUNDLER_REBUILDS = 0; global.BUNEXT_REBUILD_RETRIES = 0; + global.BUNEXT_BUILD_FAILED = false; global.BUNEXT_PAGE_FILES = []; global.BUNEXT_SKIPPED_BROWSER_MODULES = new Set(); global.BUNEXT_DIR_NAMES = dirNames; diff --git a/src/functions/grab-config.ts b/src/functions/grab-config.ts index df7a4cd..c13e1d2 100644 --- a/src/functions/grab-config.ts +++ b/src/functions/grab-config.ts @@ -20,7 +20,15 @@ export default async function grabConfig(): Promise { } return config; - } catch (error) { - return undefined; + } catch (error: any) { + // A config that fails to load (syntax error, bad export) must not be + // silently replaced by defaults — the app would run with the wrong + // configuration and nothing would explain why. + const { CONFIG_FILE } = grabDirNames(); + exitWithError( + `Failed to load config file \`${CONFIG_FILE}\`: ${ + error?.message || error + }`, + ); } } diff --git a/src/functions/server/bunext-req-handler.ts b/src/functions/server/bunext-req-handler.ts index 5380397..66d5a7c 100644 --- a/src/functions/server/bunext-req-handler.ts +++ b/src/functions/server/bunext-req-handler.ts @@ -7,6 +7,7 @@ import handleFiles from "./handle-files"; import handleBunextPublicAssets from "./handle-bunext-public-assets"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import { AppData } from "../../data/app-data"; +import { log } from "../../utils/log"; import fullRebuild from "./full-rebuild"; const HMR_RETRY_COOLDOWN_MS = 5000; @@ -86,12 +87,15 @@ export default async function bunextRequestHandler({ return response; } catch (error: any) { + log.error( + `Server Error: ${error?.message || error}\n${error?.stack || ""}`, + ); + if (is_dev) { - return new Response(`Server Error: ${error.message}`, { + return new Response(`Server Error: ${error?.message || error}`, { status: 500, }); } - console.error(`Server Error: ${error.message}`, error); return new Response("Internal Server Error", { status: 500 }); } } diff --git a/src/functions/server/full-rebuild.ts b/src/functions/server/full-rebuild.ts index 43e5923..0d43dfc 100644 --- a/src/functions/server/full-rebuild.ts +++ b/src/functions/server/full-rebuild.ts @@ -1,9 +1,36 @@ import { log } from "../../utils/log"; import allPagesESBuildContextBundler from "../bundler/all-pages-esbuild-context-bundler"; import serverPostBuildFn from "./server-post-build-fn"; -import watcherEsbuildCTX from "./watcher-esbuild-ctx"; +import { restartWatcher } from "./watcher-esbuild-ctx"; +import { logBuildErrorsOnce } from "../../utils/build-error-log"; + +// Single-flight: error recovery paths (grab-page-component retries, the +// watcher, post-build HMR pushes) all call fullRebuild, and each rebuild +// triggers serverPostBuildFn which can re-enter error handling. Without this +// guard one broken page could fan out into cascading, overlapping rebuilds +// that leave the dev server unresponsive. Concurrent callers await the same +// in-flight rebuild instead of starting a new one. +let rebuild_in_progress: Promise | undefined; export default async function fullRebuild(params?: { msg?: string }) { + if (rebuild_in_progress) { + if (params?.msg) { + log.watch(`Rebuild already in progress, skipping: ${params.msg}`); + } + return rebuild_in_progress; + } + + const run = doFullRebuild(params).finally(() => { + if (rebuild_in_progress === run) { + rebuild_in_progress = undefined; + } + }); + + rebuild_in_progress = run; + return run; +} + +async function doFullRebuild(params?: { msg?: string }) { try { const { msg } = params || {}; @@ -29,14 +56,14 @@ export default async function fullRebuild(params?: { msg?: string }) { }, }); } catch (error: any) { - log.error(error); + logBuildErrorsOnce( + [{ text: error?.message || String(error) }], + (line) => log.error(line), + ); } finally { global.BUNEXT_RECOMPILING = false; global.BUNEXT_IS_SERVER_COMPONENT = false; } - if (global.BUNEXT_PAGES_SRC_WATCHER) { - global.BUNEXT_PAGES_SRC_WATCHER.close(); - watcherEsbuildCTX(); - } + restartWatcher(); } diff --git a/src/functions/server/handle-routes.ts b/src/functions/server/handle-routes.ts index f6c53e3..6309b2a 100644 --- a/src/functions/server/handle-routes.ts +++ b/src/functions/server/handle-routes.ts @@ -7,6 +7,7 @@ import grabRouteParams from "../../utils/grab-route-params"; import grabConstants from "../../utils/grab-constants"; import grabRouter from "../../utils/grab-router"; import isDevelopment from "../../utils/is-development"; +import { log } from "../../utils/log"; import _ from "lodash"; import path from "path"; import grabDirNames from "../../utils/grab-dir-names"; @@ -19,138 +20,199 @@ type Params = { }; export default async function ({ req }: Params): Promise { - const url = new URL(req.url); - const is_dev = isDevelopment(); + try { + const url = new URL(req.url); + const is_dev = isDevelopment(); - const { MBInBytes, ServerDefaultRequestBodyLimitBytes } = grabConstants(); + const { MBInBytes, ServerDefaultRequestBodyLimitBytes } = + grabConstants(); - const router = grabRouter(); + const router = grabRouter(); - const match = router.match(url.pathname); + const match = router.match(url.pathname); - if (!match?.filePath) { - const errMsg = `Route ${url.pathname} not found`; + if (!match?.filePath) { + const errMsg = `Route ${url.pathname} not found`; + return Response.json( + { + success: false, + msg: errMsg, + }, + { + status: 404, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } + + const routeParams: BunxRouteParams = await grabRouteParams({ + req, + query: match.query, + }); + + let module: any; + const now = Date.now(); + + const relative_match_path = grabRelativePath(match.filePath); + + if ( + is_dev && + global.BUNEXT_SSR_BUNDLER_CTX_MAP?.[relative_match_path]?.path + ) { + const target_import = path.join( + ROOT_DIR, + global.BUNEXT_SSR_BUNDLER_CTX_MAP[relative_match_path].path, + ); + + module = await import(`${target_import}?t=${now}`); + } else { + const import_path = is_dev + ? `${match.filePath}?t=${now}` + : match.filePath; + module = await import(import_path); + } + + const config = module.config as BunextServerRouteConfig | undefined; + + const maxBodyBytes = config?.max_request_body_mb + ? config.max_request_body_mb * MBInBytes + : ServerDefaultRequestBodyLimitBytes; + + const contentLength = req.headers.get("content-length"); + + if (contentLength) { + const size = parseInt(contentLength, 10); + + if (size > maxBodyBytes) { + return Response.json( + { + success: false, + msg: "Request Body Too Large!", + }, + { + status: 413, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } + } else if (req.method !== "GET" && req.method !== "HEAD") { + const body = await req.arrayBuffer(); + if (body.byteLength > maxBodyBytes) { + return Response.json( + { + success: false, + msg: "Request Body Too Large!", + }, + { + status: 413, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } + + try { + routeParams.body = JSON.parse( + new TextDecoder().decode(body) || "{}", + ); + } catch (error: any) { + log.error( + `Invalid JSON body for ${url.pathname}: ${error?.message}`, + ); + return Response.json( + { + success: false, + msg: "Invalid JSON body", + }, + { + status: 400, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } + } + + const target_module = (module["default"] || + module["handler"]) as BunextAPIRouteHandler; + + let res: any; + try { + res = await target_module?.({ + ...routeParams, + }); + } catch (error: any) { + log.error( + `API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`, + ); + return Response.json( + { + success: false, + msg: `Route handler error: ${error?.message || "Internal Server Error"}`, + }, + { + status: 500, + headers: { + "Content-Type": "application/json", + }, + }, + ); + } + + if (res instanceof Response) { + if (is_dev) { + res.headers.set( + "Cache-Control", + "no-cache, no-store, must-revalidate", + ); + } + return res; + } + + if (res) { + let final_res = Response.json( + _.omit(res, [ + "bunext_api_route_res_options", + "bunext_api_route_res_transform_fn", + ]), + { + ...(res.bunext_api_route_res_options || undefined), + }, + ); + + if (res.bunext_api_route_res_transform_fn) { + final_res = await res.bunext_api_route_res_transform_fn( + final_res, + ); + } + + return final_res; + } + + return Response.json({ err: `Route handler error` }); + } catch (error: any) { + const url = new URL(req.url); + log.error( + `API Route Error (${url.pathname}): ${error?.message}\n${error?.stack || ""}`, + ); return Response.json( { success: false, - msg: errMsg, + msg: `Route handler error: ${error?.message || "Internal Server Error"}`, }, { - status: 404, + status: 500, headers: { "Content-Type": "application/json", }, }, ); } - - const routeParams: BunxRouteParams = await grabRouteParams({ - req, - query: match.query, - }); - - let module: any; - const now = Date.now(); - - const relative_match_path = grabRelativePath(match.filePath); - - if ( - is_dev && - global.BUNEXT_SSR_BUNDLER_CTX_MAP?.[relative_match_path]?.path - ) { - const target_import = path.join( - ROOT_DIR, - global.BUNEXT_SSR_BUNDLER_CTX_MAP[relative_match_path].path, - ); - - module = await import(`${target_import}?t=${now}`); - } else { - const import_path = is_dev - ? `${match.filePath}?t=${now}` - : match.filePath; - module = await import(import_path); - } - - const config = module.config as BunextServerRouteConfig | undefined; - - const maxBodyBytes = config?.max_request_body_mb - ? config.max_request_body_mb * MBInBytes - : ServerDefaultRequestBodyLimitBytes; - - const contentLength = req.headers.get("content-length"); - - if (contentLength) { - const size = parseInt(contentLength, 10); - - if (size > maxBodyBytes) { - return Response.json( - { - success: false, - msg: "Request Body Too Large!", - }, - { - status: 413, - headers: { - "Content-Type": "application/json", - }, - }, - ); - } - } else if (req.method !== "GET" && req.method !== "HEAD") { - const body = await req.arrayBuffer(); - if (body.byteLength > maxBodyBytes) { - return Response.json( - { - success: false, - msg: "Request Body Too Large!", - }, - { - status: 413, - headers: { - "Content-Type": "application/json", - }, - }, - ); - } - - routeParams.body = JSON.parse(new TextDecoder().decode(body) || "{}"); - } - - const target_module = (module["default"] || - module["handler"]) as BunextAPIRouteHandler; - - const res = await target_module?.({ - ...routeParams, - }); - - if (res instanceof Response) { - if (is_dev) { - res.headers.set( - "Cache-Control", - "no-cache, no-store, must-revalidate", - ); - } - return res; - } - - if (res) { - let final_res = Response.json( - _.omit(res, [ - "bunext_api_route_res_options", - "bunext_api_route_res_transform_fn", - ]), - { - ...(res.bunext_api_route_res_options || undefined), - }, - ); - - if (res.bunext_api_route_res_transform_fn) { - final_res = await res.bunext_api_route_res_transform_fn(final_res); - } - - return final_res; - } - - return Response.json({ err: `Route handler error` }); } diff --git a/src/functions/server/server-post-build-fn.ts b/src/functions/server/server-post-build-fn.ts index 3c657af..872d23b 100644 --- a/src/functions/server/server-post-build-fn.ts +++ b/src/functions/server/server-post-build-fn.ts @@ -58,6 +58,9 @@ export default async function serverPostBuildFn(params?: Params) { req: mock_req, return_server_res_only: true, is_hydration: true, + // This runs inside serverPostBuildFn (post-build of fullRebuild); + // letting it trigger another rebuild/retry would cascade. + no_retry: true, }); if (page_component instanceof Response) { diff --git a/src/functions/server/start-server.ts b/src/functions/server/start-server.ts index 73e8711..af1fd5e 100644 --- a/src/functions/server/start-server.ts +++ b/src/functions/server/start-server.ts @@ -2,7 +2,10 @@ import _ from "lodash"; import { log } from "../../utils/log"; import serverParamsGen from "./server-params-gen"; import isDevelopment from "../../utils/is-development"; -import watcherEsbuildCTX from "./watcher-esbuild-ctx"; +import { + restartWatcher, + watcherRestartAvailable, +} from "./watcher-esbuild-ctx"; export default async function startServer() { const serverParams = await serverParamsGen(); @@ -15,10 +18,13 @@ export default async function startServer() { log.server(`http://${server.hostname}:${server.port}`); if (is_dev) { + // Watchdog: periodically re-create the FS watcher in case it went + // stale and stopped receiving events. Skipped while a recent restart + // (e.g. by fullRebuild) is still fresh so the watcher isn't churned + // twice in a row. setInterval(() => { - if (global.BUNEXT_PAGES_SRC_WATCHER) { - global.BUNEXT_PAGES_SRC_WATCHER.close(); - watcherEsbuildCTX(); + if (watcherRestartAvailable()) { + restartWatcher(); } }, 5000); } diff --git a/src/functions/server/watcher-esbuild-ctx.ts b/src/functions/server/watcher-esbuild-ctx.ts index 0f49d53..5742d14 100644 --- a/src/functions/server/watcher-esbuild-ctx.ts +++ b/src/functions/server/watcher-esbuild-ctx.ts @@ -6,9 +6,38 @@ import { AppData } from "../../data/app-data"; import checkExcludedPatterns from "../../utils/check-excluded-patterns"; import pagesSSRBundler from "../bundler/pages-ssr-bundler"; import { log } from "../../utils/log"; +import { logBuildErrorsOnce } from "../../utils/build-error-log"; const { ROOT_DIR, BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); +// Tracks the last watcher re-creation so the 5s watchdog in start-server +// doesn't fight fullRebuild (which also restarts the watcher after a rebuild). +let last_watcher_restart = 0; + +/** + * Closes and re-creates the FS watcher. Deduped: callers that restart the + * watcher around the same time (fullRebuild, reloadWatcher, watchdog) only + * trigger a single re-creation. + */ +export function restartWatcher() { + if (!global.BUNEXT_PAGES_SRC_WATCHER) return; + + if (Date.now() - last_watcher_restart < 1000) return; + + global.BUNEXT_PAGES_SRC_WATCHER.close(); + global.BUNEXT_PAGES_SRC_WATCHER = undefined; + last_watcher_restart = Date.now(); + watcherEsbuildCTX(); +} + +/** + * True when the watcher was last (re)created long enough ago that the + * watchdog should consider re-creating it again. + */ +export function watcherRestartAvailable() { + return Date.now() - last_watcher_restart > 6000; +} + export default async function watcherEsbuildCTX() { const pages_src_watcher = watch( ROOT_DIR, @@ -114,8 +143,16 @@ export default async function watcherEsbuildCTX() { global.BUNEXT_IS_SERVER_COMPONENT = true; } - if (global.BUNEXT_BUNDLER_CTX) { + if ( + global.BUNEXT_BUNDLER_CTX && + !global.BUNEXT_BUNDLER_CTX_DISPOSED + ) { await global.BUNEXT_BUNDLER_CTX.rebuild(); + } else if (global.BUNEXT_BUNDLER_CTX_DISPOSED) { + await fullRebuild({ + msg: `Restarting Bundler ...`, + }); + return; } if (filename.match(/(404|500)\.tsx?/)) { @@ -169,8 +206,17 @@ export default async function watcherEsbuildCTX() { await fullRebuild({ msg: `${type} ${action}: ${filename}. Rebuilding ...`, }); - } catch (error) { - log.error(`Watcher rebuild failed: ${error}`); + } catch (error: any) { + logBuildErrorsOnce( + [ + { + text: `Watcher rebuild failed: ${ + error?.message || String(error) + }`, + }, + ], + (line) => log.error(line), + ); } finally { if (owns_recompile) { global.BUNEXT_RECOMPILING = false; @@ -184,8 +230,5 @@ export default async function watcherEsbuildCTX() { } function reloadWatcher() { - if (global.BUNEXT_PAGES_SRC_WATCHER) { - global.BUNEXT_PAGES_SRC_WATCHER.close(); - watcherEsbuildCTX(); - } + restartWatcher(); } diff --git a/src/functions/server/web-pages/generate-web-html.tsx b/src/functions/server/web-pages/generate-web-html.tsx index 58ea741..06ac4b4 100644 --- a/src/functions/server/web-pages/generate-web-html.tsx +++ b/src/functions/server/web-pages/generate-web-html.tsx @@ -185,10 +185,19 @@ export default async function genWebHTML({ console.debug = () => {}; let htmlBody: string; + let render_error: any; + let has_render_error = false; + try { const stream = await renderToReadableStream(final_component, { onError(error: any) { - if (error.message.includes('unique "key" prop')) return; + if (error?.message?.includes('unique "key" prop')) return; + + if (!has_render_error) { + has_render_error = true; + render_error = error; + } + originalConsole.error(error); }, }); @@ -198,6 +207,13 @@ export default async function genWebHTML({ Object.assign(console, originalConsole); } + // A component crash during SSR must surface as a proper error (handled + // upstream as a 500 page). React only reports these to `onError`; without + // rethrowing, the user gets a 200 with broken/blank markup. + if (has_render_error) { + throw render_error; + } + html += htmlBody; return html; diff --git a/src/functions/server/web-pages/grab-page-component.tsx b/src/functions/server/web-pages/grab-page-component.tsx index f7d084f..2cc7cda 100644 --- a/src/functions/server/web-pages/grab-page-component.tsx +++ b/src/functions/server/web-pages/grab-page-component.tsx @@ -11,6 +11,7 @@ import isDevelopment from "../../../utils/is-development"; import { existsSync } from "fs"; import grabDirNames from "../../../utils/grab-dir-names"; import grabRelativePath from "../../../utils/grab-relative-path"; +import { logBuildErrorsOnce } from "../../../utils/build-error-log"; const { BUNX_BUNDLER_ERROR_EXIT_FILE, ROOT_DIR } = grabDirNames(); @@ -28,6 +29,12 @@ type Params = { file_path?: string; debug?: boolean; retry?: boolean; + /** + * Skip the dev auto-rebuild/retry recovery path. Used by internal callers + * (e.g. HMR post-build) where triggering yet another fullRebuild on top + * of the rebuild that's already in progress would cascade. + */ + no_retry?: boolean; return_server_res_only?: boolean; skip_server_res?: boolean; is_hydration?: boolean; @@ -43,6 +50,7 @@ export default async function grabPageComponent( return_server_res_only, skip_server_res, is_hydration, + no_retry, } = params; const url = req?.url ? new URL(req.url) : undefined; @@ -104,13 +112,21 @@ export default async function grabPageComponent( let retries = 0; const MAX_RETRIES = 2; - while (retries < MAX_RETRIES) { + // Only retry transient failures: if the last rebuild failed + // (BUNEXT_BUILD_FAILED), the file is broken and more rebuilds + // will just repeat the same error. + while ( + retries < MAX_RETRIES && + !global.BUNEXT_BUILD_FAILED + ) { await fullRebuild({ msg: `Retrying Bundle map for file \`${relative_file_path}\``, }); await Bun.sleep(1000); - bundledMap = global.BUNEXT_BUNDLER_CTX_MAP[relative_file_path]; + bundledMap = + global.BUNEXT_BUNDLER_CTX_MAP[relative_file_path]; if (bundledMap?.path) break; + retries++; } if (!bundledMap?.path) { @@ -170,8 +186,13 @@ export default async function grabPageComponent( error?.name === "NotFoundError" || error?.status === 404; - if (!params.retry && is_dev) { - while (global.BUNEXT_REBUILD_RETRIES < 2) { + if (!params.retry && !params.no_retry && is_dev) { + // Skip the retry cascade when the last rebuild failed: the error + // is persistent and more rebuilds would repeat it endlessly. + while ( + !global.BUNEXT_BUILD_FAILED && + global.BUNEXT_REBUILD_RETRIES < 2 + ) { global.BUNEXT_REBUILD_RETRIES = global.BUNEXT_REBUILD_RETRIES + 1; @@ -198,10 +219,18 @@ export default async function grabPageComponent( if (is404) { global.BUNEXT_IS_404_PAGE = true; } else { - log.error(`Error Grabbing Page Component: ${error.message}`); - log.error(`Page: ${passed_file_path || url?.pathname}`); + logBuildErrorsOnce( + [ + { + text: `Error Grabbing Page Component: ${error.message}; Page: ${ + passed_file_path || url?.pathname + }`, + }, + ], + (line) => log.error(line), + ); - if (is_dev) { + if (is_dev && !params.no_retry && !global.BUNEXT_BUILD_FAILED) { fullRebuild(); } } diff --git a/src/functions/server/web-pages/handle-web-pages.tsx b/src/functions/server/web-pages/handle-web-pages.tsx index bdc7b4e..f418a1a 100644 --- a/src/functions/server/web-pages/handle-web-pages.tsx +++ b/src/functions/server/web-pages/handle-web-pages.tsx @@ -1,5 +1,6 @@ import isDevelopment from "../../../utils/is-development"; import { log } from "../../../utils/log"; +import { logBuildErrorsOnce } from "../../../utils/build-error-log"; import getCache from "../../cache/get-cache"; import generateWebPageResponseFromComponentReturn from "./generate-web-page-response-from-component-return"; import grabPageComponent from "./grab-page-component"; @@ -45,7 +46,16 @@ export default async function handleWebPages({ ...componentRes, }); } catch (error: any) { - log.error(`Error Handling Web Page: ${error.message}`, error); + logBuildErrorsOnce( + [ + { + text: `Error Handling Web Page: ${ + error?.message || String(error) + }`, + }, + ], + (line) => log.error(line), + ); const componentRes = await grabPageErrorComponent({ error, diff --git a/src/functions/write-error-file.ts b/src/functions/write-error-file.ts index 20e48e3..053fc80 100644 --- a/src/functions/write-error-file.ts +++ b/src/functions/write-error-file.ts @@ -1,34 +1,62 @@ import path from "path"; -import { mkdirSync, writeFileSync } from "fs"; +import { existsSync, mkdirSync, writeFileSync } from "fs"; import grabDirNames from "../utils/grab-dir-names"; const { BUNX_BUNDLER_ERROR_EXIT_FILE } = grabDirNames(); +type Params = { + /** + * Only include when the exiting process actually crashed with an error. + * Bun's spawn `onExit` `error` is only set on spawn failures, so callers + * that DO have the error (crash handlers) should pass `force: true`. + */ + force?: boolean; + error?: unknown; + exitCode?: number | null; +}; + +/** + * Writes the `BUNDLER_EXIT.txt` flag that the bundler pipeline watches to + * detect that the dev/prod child process previously crashed. + * + * Guardrails: + * - Never writes for clean exits (exitCode === 0) or signal deaths where no + * error is available (Ctrl+C must not leave a stale error file behind). + * - The supervisor writes only when the file does not already exist, so the + * crash handler's richer error/stack payload is not clobbered. + */ export default function writeErrorFile({ - exitCode, + force, error, -}: { - error?: Bun.ErrorLike; - exitCode: number | null; -}) { + exitCode, +}: Params) { + const has_error = error != null; + const abnormal_exit = exitCode != null && exitCode !== 0; + + if (!has_error && !abnormal_exit) return; + if (!force && existsSync(BUNX_BUNDLER_ERROR_EXIT_FILE)) return; + + const err = + error instanceof Error + ? error + : typeof error === "string" + ? new Error(error) + : undefined; + + const message = err?.message || "Unknown error"; + const stack = err?.stack || "N/A"; + let txt = ``; txt += `Bunext Error\n`; txt += `============================================\n`; - txt += `ERROR: ${error?.message}\n`; - txt += `EXIT_CODE: ${exitCode || 0}\n`; - txt += `CALL_STACK: ${error?.stack}\n`; + txt += `ERROR: ${message}\n`; + txt += `EXIT_CODE: ${exitCode ?? "N/A"}\n`; + txt += `CALL_STACK:\n${stack}\n`; - mkdirSync(path.dirname(BUNX_BUNDLER_ERROR_EXIT_FILE), { recursive: true }); - writeFileSync(BUNX_BUNDLER_ERROR_EXIT_FILE, txt); + try { + mkdirSync(path.dirname(BUNX_BUNDLER_ERROR_EXIT_FILE), { + recursive: true, + }); + writeFileSync(BUNX_BUNDLER_ERROR_EXIT_FILE, txt); + } catch {} } - -// log.info("Running development server ..."); - -// try { -// rmSync(HYDRATION_DST_DIR, { recursive: true }); -// rmSync(BUNX_CWD_PAGES_REWRITE_DIR, { recursive: true }); -// } catch (error) {} - -// await bunextInit(); - -// await startServer(); diff --git a/src/utils/build-error-log.ts b/src/utils/build-error-log.ts new file mode 100644 index 0000000..f6b5301 --- /dev/null +++ b/src/utils/build-error-log.ts @@ -0,0 +1,64 @@ +// Logs build/render errors exactly once while the same error state persists. +// +// Dev recovery paths (watcher, request retries, post-build HMR) re-attempt a +// rebuild for every file edit and request while a syntax error is still in the +// file. Without deduplication that one error is printed again on every +// attempt — an endless spiral of identical log lines. The signature is +// cleared after a successful build, so the same error later (after a fix and +// regression) is logged again as a new incident. + +export type BuildErrorLike = { + text?: string; + message?: string; + id?: string; + location?: { file?: string; line?: number; column?: number } | null; +}; + +let last_build_error_signature: string | undefined; + +function errorSignature(errors: BuildErrorLike[]): string { + return errors + .map((e) => { + const text = e.text || e.message || e.id || ""; + const loc = e.location + ? `${e.location.file}:${e.location.line}:${e.location.column}` + : ""; + return `${text}${loc}`; + }) + .join("\n---\n"); +} + +/** + * Logs each error once — identical error sets are suppressed until the + * signature changes or `resetBuildErrorLog()` is called (after a successful + * build). Returns true when something was logged. + */ +export function logBuildErrorsOnce( + errors: BuildErrorLike[], + logLine: (line: string) => void = (line) => console.error(line), +): boolean { + if (!errors || errors.length === 0) return false; + + const signature = errorSignature(errors); + if (signature === last_build_error_signature) return false; + + last_build_error_signature = signature; + + for (const e of errors) { + const text = e.text || e.message || e.id || "Build Error"; + const loc = e.location + ? ` (${e.location.file}:${e.location.line}:${e.location.column})` + : ""; + logLine(`${text}${loc}`); + } + + return true; +} + +/** + * Clears the dedup state so a new incident with the same signature is logged + * again. Call after a successful build. + */ +export function resetBuildErrorLog(): void { + last_build_error_signature = undefined; +}