From b17bd7bb542aa4a4a03545a66d321e48c70eb1e2 Mon Sep 17 00:00:00 2001
From: Hannes Hauswedell
Date: Thu, 16 Apr 2026 21:17:21 +0200
Subject: [PATCH 1/2] feature: FreeBSD support
assisted-by: Claude Opus 4.6
---
package.json | 7 +--
scripts/next-runner.js | 17 ++++++
scripts/postinstall.js | 114 +++++++++++++++++++++++++++++++++++++++++
3 files changed, 135 insertions(+), 3 deletions(-)
create mode 100644 scripts/next-runner.js
create mode 100644 scripts/postinstall.js
diff --git a/package.json b/package.json
index 00da247b..fbe35ef8 100644
--- a/package.json
+++ b/package.json
@@ -3,8 +3,8 @@
"version": "1.22.0",
"private": true,
"scripts": {
- "dev": "next dev",
- "build": "next build && node scripts/postbuild.js",
+ "dev": "node scripts/next-runner.js dev",
+ "build": "node scripts/next-runner.js build && node scripts/postbuild.js",
"start": "next start",
"lint": "eslint .",
"test": "vitest",
@@ -14,7 +14,8 @@
"mock:data:notes": "tsx tests/mock-data/note-generator.ts",
"docker:test": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml build && docker compose -f docker-compose.test.yml up -d",
"docker:test:logs": "docker compose -f docker-compose.test.yml logs -f",
- "docker:test:restart": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml up -d"
+ "docker:test:restart": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml up -d",
+ "postinstall": "node scripts/postinstall.js"
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
diff --git a/scripts/next-runner.js b/scripts/next-runner.js
new file mode 100644
index 00000000..ce2a1390
--- /dev/null
+++ b/scripts/next-runner.js
@@ -0,0 +1,17 @@
+'use strict';
+
+/**
+ * Wrapper around `next` that appends --webpack on FreeBSD, where Turbopack is
+ * unavailable due to missing native bindings. On all other platforms the
+ * arguments are passed through unchanged, preserving Turbopack as the default.
+ */
+
+const { spawnSync } = require('child_process');
+
+const args = process.argv.slice(2);
+if (process.platform === 'freebsd') {
+ args.push('--webpack');
+}
+
+const result = spawnSync('next', args, { stdio: 'inherit', shell: true });
+process.exit(result.status ?? 1);
diff --git a/scripts/postinstall.js b/scripts/postinstall.js
new file mode 100644
index 00000000..8637958d
--- /dev/null
+++ b/scripts/postinstall.js
@@ -0,0 +1,114 @@
+'use strict';
+
+/**
+ * Patches @swc/core/binding.js on FreeBSD so that modules importing @swc/core
+ * do not crash at require time when no native binary is available.
+ *
+ * Context: next-intl (and @serwist/turbopack) depend on @swc/core, whose
+ * binding.js throws immediately on unsupported platforms. On FreeBSD there is
+ * no native binary and no published WASM fallback package, so the import fails
+ * before the build even starts. The patch makes binding.js return a lazy stub
+ * on FreeBSD instead — methods only throw when actually called, which is never
+ * the case in this project (next-intl's experimental.extract is not enabled).
+ */
+
+const fs = require('fs');
+const path = require('path');
+
+if (process.platform !== 'freebsd') {
+ process.exit(0);
+}
+
+const NEEDLE = `if (!nativeBinding) {
+ if (loadErrors.length > 0) {
+ // TODO Link to documentation with potential fixes
+ // - The package owner could build/publish bindings for this arch
+ // - The user may need to bundle the correct files
+ // - The user may need to re-install node_modules to get new packages
+ throw new Error('Failed to load native binding', { cause: loadErrors })
+ }
+ throw new Error(\`Failed to load native binding\`)
+}`;
+
+const REPLACEMENT = `if (!nativeBinding) {
+ if (process.platform === 'freebsd') {
+ // FreeBSD: no native or WASM binary is available from @swc/core.
+ // Return a stub so modules that import @swc/core without using it can load.
+ // Any actual call to @swc/core methods will throw at call time.
+ const noBinding = () => {
+ throw new Error(
+ '@swc/core: no native or WASM binding available for FreeBSD. ' +
+ 'Build @swc/core from source or provide a WASM fallback.'
+ )
+ }
+ nativeBinding = {
+ Compiler: class { constructor() { noBinding() } },
+ JsCompiler: class { constructor() { noBinding() } },
+ analyze: noBinding, bundle: noBinding,
+ getTargetTriple: () => 'x86_64-unknown-freebsd',
+ initCustomTraceSubscriber: noBinding, minify: noBinding,
+ minifySync: noBinding, newMangleNameCache: noBinding,
+ parse: noBinding, parseFile: noBinding,
+ parseFileSync: noBinding, parseSync: noBinding,
+ print: noBinding, printSync: noBinding,
+ transform: noBinding, transformFile: noBinding,
+ transformFileSync: noBinding, transformSync: noBinding,
+ }
+ } else if (loadErrors.length > 0) {
+ // TODO Link to documentation with potential fixes
+ // - The package owner could build/publish bindings for this arch
+ // - The user may need to bundle the correct files
+ // - The user may need to re-install node_modules to get new packages
+ throw new Error('Failed to load native binding', { cause: loadErrors })
+ } else {
+ throw new Error(\`Failed to load native binding\`)
+ }
+}`;
+
+// Find all @swc/core/binding.js files under node_modules (handles hoisted and
+// nested installs, e.g. node_modules/@swc/core and
+// node_modules/next-intl/node_modules/@swc/core).
+function findBindingFiles(dir, results = []) {
+ const swcCore = path.join(dir, '@swc', 'core', 'binding.js');
+ if (fs.existsSync(swcCore)) {
+ results.push(swcCore);
+ }
+ try {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (!entry.isDirectory()) continue;
+ if (entry.name === '@swc') continue; // already checked above
+ const nested = path.join(dir, entry.name, 'node_modules');
+ if (fs.existsSync(nested)) {
+ findBindingFiles(nested, results);
+ }
+ }
+ } catch (_) {}
+ return results;
+}
+
+const nodeModules = path.join(__dirname, '..', 'node_modules');
+const bindingFiles = findBindingFiles(nodeModules);
+
+if (bindingFiles.length === 0) {
+ console.warn('postinstall: no @swc/core/binding.js found — skipping FreeBSD patch.');
+ process.exit(0);
+}
+
+let patched = 0;
+for (const file of bindingFiles) {
+ const src = fs.readFileSync(file, 'utf8');
+ if (src.includes('x86_64-unknown-freebsd')) {
+ continue; // already patched
+ }
+ if (!src.includes(NEEDLE)) {
+ console.warn(`postinstall: unexpected binding.js format at ${file} — skipping.`);
+ continue;
+ }
+ fs.writeFileSync(file, src.replace(NEEDLE, REPLACEMENT));
+ patched++;
+ console.log(`postinstall: patched ${file}`);
+}
+
+if (patched === 0) {
+ console.log('postinstall: @swc/core/binding.js already patched, nothing to do.');
+}
From 31a05de1aa07028ffc598785b770a33aa9d7a70c Mon Sep 17 00:00:00 2001
From: fccview
Date: Mon, 27 Apr 2026 09:44:26 +0100
Subject: [PATCH 2/2] change this to work with the new patching system instead
---
howto/ENV-VARIABLES.md | 1 +
howto/PATCHES.md | 18 ++++++
package.json | 7 +--
patches/freebsd_20260427.js | 110 ++++++++++++++++++++++++++++++++++
scripts/next-runner.js | 17 ------
scripts/postinstall.js | 114 ------------------------------------
6 files changed, 132 insertions(+), 135 deletions(-)
create mode 100644 patches/freebsd_20260427.js
delete mode 100644 scripts/next-runner.js
delete mode 100644 scripts/postinstall.js
diff --git a/howto/ENV-VARIABLES.md b/howto/ENV-VARIABLES.md
index 6a7431ea..26eaa952 100644
--- a/howto/ENV-VARIABLES.md
+++ b/howto/ENV-VARIABLES.md
@@ -38,6 +38,7 @@ OIDC_ADMIN_GROUPS=admins
- `DISABLE_BRUTEFORCE_PROTECTION=yes` Optional. Disables brute force protection for local login authentication. By default, accounts are temporarily locked after 3 failed login attempts with exponential delays (10s, 30s, 60s, etc.). Set to `yes` to completely disable this security feature.
- `ENABLE_PWA_ZOOM=yes` Optional. Enables zoomming on the PWA for accessibility reasons.
- `JOTTY_BODY_SIZE_LIMIT=100mb` Optional. Maximum request body size accepted by Server Actions (uploads, drawio attachments, avatars, etc.). Defaults to `100mb`. Accepts `b`, `kb`, `mb`, `gb` (e.g. `50mb`, `2gb`). Applied at container start via the runtime patcher — see [Runtime Patches](./PATCHES.md).
+- `JOTTY_FREEBSD=1` Optional. **FreeBSD only.** Enables the FreeBSD compatibility patch which stubs `@swc/core` (no native or WASM binary is published for FreeBSD) and forces Next.js to use webpack instead of Turbopack. Has no effect on Linux/macOS/Windows — leave unset everywhere else. Applied at container start via the runtime patcher — see [Runtime Patches](./PATCHES.md).
## SSO Configuration (Optional)
diff --git a/howto/PATCHES.md b/howto/PATCHES.md
index b5549b60..af674a5d 100644
--- a/howto/PATCHES.md
+++ b/howto/PATCHES.md
@@ -55,3 +55,21 @@ environment:
```
+
+
+freebsd_20260427.js — FreeBSD compatibility (stub @swc/core, force webpack)
+
+FreeBSD has no prebuilt native binary for `@swc/core` and no published WASM fallback, so any module that imports it (next-intl, @serwist/turbopack) crashes at require time. Turbopack is also unavailable for the same reason. This patch:
+
+1. Stubs `node_modules/@swc/core/binding.js` (hoisted + nested copies) so imports succeed. Stub methods only throw when actually called — which never happens in this project.
+2. Patches `next/dist/lib/bundler.js` `parseBundlerArgs()` to force the webpack bundler, so Next never tries to load Turbopack.
+
+- **Gated on:** `JOTTY_FREEBSD` env var. Without it, the patch is a strict no-op — nothing under `node_modules` is read or modified.
+- **Default:** disabled (Linux/macOS/Windows users should leave it unset).
+
+```yaml
+environment:
+ - JOTTY_FREEBSD=1
+```
+
+
diff --git a/package.json b/package.json
index fbe35ef8..00da247b 100644
--- a/package.json
+++ b/package.json
@@ -3,8 +3,8 @@
"version": "1.22.0",
"private": true,
"scripts": {
- "dev": "node scripts/next-runner.js dev",
- "build": "node scripts/next-runner.js build && node scripts/postbuild.js",
+ "dev": "next dev",
+ "build": "next build && node scripts/postbuild.js",
"start": "next start",
"lint": "eslint .",
"test": "vitest",
@@ -14,8 +14,7 @@
"mock:data:notes": "tsx tests/mock-data/note-generator.ts",
"docker:test": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml build && docker compose -f docker-compose.test.yml up -d",
"docker:test:logs": "docker compose -f docker-compose.test.yml logs -f",
- "docker:test:restart": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml up -d",
- "postinstall": "node scripts/postinstall.js"
+ "docker:test:restart": "docker compose -f docker-compose.test.yml down && docker compose -f docker-compose.test.yml up -d"
},
"dependencies": {
"@dnd-kit/core": "6.3.1",
diff --git a/patches/freebsd_20260427.js b/patches/freebsd_20260427.js
new file mode 100644
index 00000000..d5e1f170
--- /dev/null
+++ b/patches/freebsd_20260427.js
@@ -0,0 +1,110 @@
+/**
+ * FreeBSD Compatibility Patch (2026-04-27)
+ *
+ * FreeBSD ships without prebuilt native binaries for @swc/core and Turbopack,
+ * so a stock `next dev`/`next build` crashes at require time. This patch:
+ *
+ * 1. Stubs node_modules/@swc/core/binding.js so modules that import @swc/core
+ * (next-intl, @serwist/turbopack) do not throw at load. Stub methods only
+ * throw when actually called — which never happens in this project.
+ * 2. Forces the Next.js bundler selector to webpack by injecting a guard at
+ * the top of node_modules/next/dist/lib/bundler.js parseBundlerArgs(),
+ * so Turbopack (which has no FreeBSD binary) is never chosen.
+ *
+ * Gated on the JOTTY_FREEBSD env var: this patch is a no-op everywhere else.
+ * Idempotent: re-runs detect prior application and skip.
+ */
+
+const fs = require("fs");
+const path = require("path");
+
+const _SWC_NEEDLE = `if (!nativeBinding) {
+ if (loadErrors.length > 0) {
+ // TODO Link to documentation with potential fixes
+ // - The package owner could build/publish bindings for this arch
+ // - The user may need to bundle the correct files
+ // - The user may need to re-install node_modules to get new packages
+ throw new Error('Failed to load native binding', { cause: loadErrors })
+ }
+ throw new Error(\`Failed to load native binding\`)
+}`;
+
+const _SWC_REPLACEMENT = `if (!nativeBinding) {
+ if (process.env.JOTTY_FREEBSD) {
+ const noBinding = () => {
+ throw new Error(
+ '@swc/core: no native or WASM binding available for FreeBSD. ' +
+ 'Build @swc/core from source or provide a WASM fallback.'
+ )
+ }
+ nativeBinding = {
+ Compiler: class { constructor() { noBinding() } },
+ JsCompiler: class { constructor() { noBinding() } },
+ analyze: noBinding, bundle: noBinding,
+ getTargetTriple: () => 'x86_64-unknown-freebsd',
+ initCustomTraceSubscriber: noBinding, minify: noBinding,
+ minifySync: noBinding, newMangleNameCache: noBinding,
+ parse: noBinding, parseFile: noBinding,
+ parseFileSync: noBinding, parseSync: noBinding,
+ print: noBinding, printSync: noBinding,
+ transform: noBinding, transformFile: noBinding,
+ transformFileSync: noBinding, transformSync: noBinding,
+ }
+ } else if (loadErrors.length > 0) {
+ throw new Error('Failed to load native binding', { cause: loadErrors })
+ } else {
+ throw new Error(\`Failed to load native binding\`)
+ }
+}`;
+
+const _BUNDLER_NEEDLE = `function parseBundlerArgs(options) {`;
+const _BUNDLER_REPLACEMENT = `function parseBundlerArgs(options) {
+ if (process.env.JOTTY_FREEBSD) { options.webpack = true; options.turbopack = false; options.turbo = false; }`;
+const _BUNDLER_MARKER = `JOTTY_FREEBSD`;
+
+const _findSwcBindings = (dir, results = []) => {
+ const swcCore = path.join(dir, "@swc", "core", "binding.js");
+ if (fs.existsSync(swcCore)) results.push(swcCore);
+ try {
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
+ if (!entry.isDirectory() || entry.name === "@swc") continue;
+ const nested = path.join(dir, entry.name, "node_modules");
+ if (fs.existsSync(nested)) _findSwcBindings(nested, results);
+ }
+ } catch (_) { }
+ return results;
+};
+
+const _patchSwc = (file) => {
+ const src = fs.readFileSync(file, "utf8");
+ if (src.includes("x86_64-unknown-freebsd")) return "noop";
+ if (!src.includes(_SWC_NEEDLE)) return "skipped";
+ fs.writeFileSync(file, src.replace(_SWC_NEEDLE, _SWC_REPLACEMENT));
+ return "patched";
+};
+
+const _patchBundler = (file) => {
+ if (!fs.existsSync(file)) return "missing";
+ const src = fs.readFileSync(file, "utf8");
+ if (src.includes(_BUNDLER_MARKER)) return "noop";
+ if (!src.includes(_BUNDLER_NEEDLE)) return "skipped";
+ fs.writeFileSync(file, src.replace(_BUNDLER_NEEDLE, _BUNDLER_REPLACEMENT));
+ return "patched";
+};
+
+module.exports = {
+ name: "freebsd_20260427",
+ apply: (ctx) => {
+ if (!process.env.JOTTY_FREEBSD) return "skipped (JOTTY_FREEBSD not set)";
+
+ const counts = { patched: 0, noop: 0, skipped: 0, missing: 0 };
+ const nodeModules = path.join(ctx.projectRoot, "node_modules");
+
+ for (const file of _findSwcBindings(nodeModules)) {
+ counts[_patchSwc(file)]++;
+ }
+ counts[_patchBundler(path.join(nodeModules, "next", "dist", "lib", "bundler.js"))]++;
+
+ return `swc+bundler patched=${counts.patched}, noop=${counts.noop}, skipped=${counts.skipped}, missing=${counts.missing}`;
+ },
+};
diff --git a/scripts/next-runner.js b/scripts/next-runner.js
deleted file mode 100644
index ce2a1390..00000000
--- a/scripts/next-runner.js
+++ /dev/null
@@ -1,17 +0,0 @@
-'use strict';
-
-/**
- * Wrapper around `next` that appends --webpack on FreeBSD, where Turbopack is
- * unavailable due to missing native bindings. On all other platforms the
- * arguments are passed through unchanged, preserving Turbopack as the default.
- */
-
-const { spawnSync } = require('child_process');
-
-const args = process.argv.slice(2);
-if (process.platform === 'freebsd') {
- args.push('--webpack');
-}
-
-const result = spawnSync('next', args, { stdio: 'inherit', shell: true });
-process.exit(result.status ?? 1);
diff --git a/scripts/postinstall.js b/scripts/postinstall.js
deleted file mode 100644
index 8637958d..00000000
--- a/scripts/postinstall.js
+++ /dev/null
@@ -1,114 +0,0 @@
-'use strict';
-
-/**
- * Patches @swc/core/binding.js on FreeBSD so that modules importing @swc/core
- * do not crash at require time when no native binary is available.
- *
- * Context: next-intl (and @serwist/turbopack) depend on @swc/core, whose
- * binding.js throws immediately on unsupported platforms. On FreeBSD there is
- * no native binary and no published WASM fallback package, so the import fails
- * before the build even starts. The patch makes binding.js return a lazy stub
- * on FreeBSD instead — methods only throw when actually called, which is never
- * the case in this project (next-intl's experimental.extract is not enabled).
- */
-
-const fs = require('fs');
-const path = require('path');
-
-if (process.platform !== 'freebsd') {
- process.exit(0);
-}
-
-const NEEDLE = `if (!nativeBinding) {
- if (loadErrors.length > 0) {
- // TODO Link to documentation with potential fixes
- // - The package owner could build/publish bindings for this arch
- // - The user may need to bundle the correct files
- // - The user may need to re-install node_modules to get new packages
- throw new Error('Failed to load native binding', { cause: loadErrors })
- }
- throw new Error(\`Failed to load native binding\`)
-}`;
-
-const REPLACEMENT = `if (!nativeBinding) {
- if (process.platform === 'freebsd') {
- // FreeBSD: no native or WASM binary is available from @swc/core.
- // Return a stub so modules that import @swc/core without using it can load.
- // Any actual call to @swc/core methods will throw at call time.
- const noBinding = () => {
- throw new Error(
- '@swc/core: no native or WASM binding available for FreeBSD. ' +
- 'Build @swc/core from source or provide a WASM fallback.'
- )
- }
- nativeBinding = {
- Compiler: class { constructor() { noBinding() } },
- JsCompiler: class { constructor() { noBinding() } },
- analyze: noBinding, bundle: noBinding,
- getTargetTriple: () => 'x86_64-unknown-freebsd',
- initCustomTraceSubscriber: noBinding, minify: noBinding,
- minifySync: noBinding, newMangleNameCache: noBinding,
- parse: noBinding, parseFile: noBinding,
- parseFileSync: noBinding, parseSync: noBinding,
- print: noBinding, printSync: noBinding,
- transform: noBinding, transformFile: noBinding,
- transformFileSync: noBinding, transformSync: noBinding,
- }
- } else if (loadErrors.length > 0) {
- // TODO Link to documentation with potential fixes
- // - The package owner could build/publish bindings for this arch
- // - The user may need to bundle the correct files
- // - The user may need to re-install node_modules to get new packages
- throw new Error('Failed to load native binding', { cause: loadErrors })
- } else {
- throw new Error(\`Failed to load native binding\`)
- }
-}`;
-
-// Find all @swc/core/binding.js files under node_modules (handles hoisted and
-// nested installs, e.g. node_modules/@swc/core and
-// node_modules/next-intl/node_modules/@swc/core).
-function findBindingFiles(dir, results = []) {
- const swcCore = path.join(dir, '@swc', 'core', 'binding.js');
- if (fs.existsSync(swcCore)) {
- results.push(swcCore);
- }
- try {
- for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
- if (!entry.isDirectory()) continue;
- if (entry.name === '@swc') continue; // already checked above
- const nested = path.join(dir, entry.name, 'node_modules');
- if (fs.existsSync(nested)) {
- findBindingFiles(nested, results);
- }
- }
- } catch (_) {}
- return results;
-}
-
-const nodeModules = path.join(__dirname, '..', 'node_modules');
-const bindingFiles = findBindingFiles(nodeModules);
-
-if (bindingFiles.length === 0) {
- console.warn('postinstall: no @swc/core/binding.js found — skipping FreeBSD patch.');
- process.exit(0);
-}
-
-let patched = 0;
-for (const file of bindingFiles) {
- const src = fs.readFileSync(file, 'utf8');
- if (src.includes('x86_64-unknown-freebsd')) {
- continue; // already patched
- }
- if (!src.includes(NEEDLE)) {
- console.warn(`postinstall: unexpected binding.js format at ${file} — skipping.`);
- continue;
- }
- fs.writeFileSync(file, src.replace(NEEDLE, REPLACEMENT));
- patched++;
- console.log(`postinstall: patched ${file}`);
-}
-
-if (patched === 0) {
- console.log('postinstall: @swc/core/binding.js already patched, nothing to do.');
-}