OverviewFilesCommitsIssuesDocsReleasesPeers

godot-keet commits / 1e04bbc

launcher: flatten pgh symlinked node_modules before loading keet-daemon
A pgh content-addressed install lays out node_modules/<pkg> as symlinks into node_modules/.pgh/<hash>/. keet-core 4.x's Bundlebee resolver expects a flat npm layout (Bare caches by realpath), so on a fresh pgh install keet-daemon threw "failed to find module: /node_modules/b4a/index.js" on first bundle load — room invites and chat sends failed (reads worked, masking it). The old standalone install only worked because its checkout happened to be flat. Port the proven hyper-tps workaround: - load keet-daemon LAZILY (loadKeetDaemon) instead of a top-level import, so its keet-core resolution runs only AFTER the flatten; - flattenIfNeeded() replaces top-level (+ scoped) node_modules symlinks with real copies, idempotent (skips when already flat); - main() flattens then loads the daemon before ensureDaemon(). - ship flatten-node-modules.cjs (root) + add it to ci.yaml stage-fat-tree. Note: godot-keet's scripts/ is the GDScript dir, so the flatten script lives at the repo root (not scripts/ as in hyper-tps). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1e04bbc5cacc86f7803d58942bd327073d24b812
by plankatron <fleeky@gmail.com> · 2026-06-20T21:07:37.000Z

Changed files (3) +170 -2

.pear-git/ci.yaml +1 -1
……@@ -58,7 +58,7 @@ steps:
5858 mkdir -p "$STAGE/by-arch/${HOST}/build/linux/godot-keet"
5959
6060 # Host-independent at top level
61 cp index.js package.json version.txt "$STAGE/"
61 cp index.js package.json version.txt flatten-node-modules.cjs "$STAGE/"
6262 cp -r daemon "$STAGE/"
6363 cp -r node_modules "$STAGE/"
6464
flatten-node-modules.cjs +88 -0
……@@ -0,0 +1,88 @@
1#!/usr/bin/env node
2/**
3 * Flatten only TOP-LEVEL symlinks in node_modules.
4 *
5 * Why this specifically: pgh's content-addressed materializer makes
6 * `node_modules/<pkg>` and `node_modules/@scope/<pkg>` symlinks into
7 * `node_modules/.pgh/<hash>/`. Bundlebee (lazy bundle loader in
8 * keet-core 4.x) looks up modules via require.cache, but Bare caches
9 * by realpath — so the bundle's `/node_modules/<pkg>/index.js`
10 * references never match.
11 *
12 * Fix: replace each top-level symlink with a real copy of its target
13 * (resolved through the symlink). Do NOT recursively flatten nested
14 * `node_modules/<pkg>/node_modules/<sub>` symlinks — they can stay
15 * pointing into .pgh, because npm-style hoisting means most lookups
16 * find their match at the top level anyway. Keep .pgh in place as
17 * the fallback for any nested symlink that DOES need resolution.
18 *
19 * Cycle handling: avoided entirely by only processing top-level
20 * entries (one or two levels deep). No recursion into copied content.
21 */
22
23'use strict'
24
25const fs = require('fs')
26const path = require('path')
27const { execSync } = require('child_process')
28
29const root = process.argv[2] || 'node_modules'
30if (!fs.existsSync(root)) {
31 console.error('flatten-node-modules: not found:', root)
32 process.exit(1)
33}
34
35const start = Date.now()
36let replaced = 0
37let failed = 0
38
39function flattenLink (linkPath) {
40 let real
41 try { real = fs.realpathSync(linkPath) }
42 catch { failed++; return }
43 let st
44 try { st = fs.statSync(real) }
45 catch { failed++; return }
46 if (!st.isDirectory()) {
47 // a symlink to a file — just copy the file content
48 const data = fs.readFileSync(real)
49 fs.unlinkSync(linkPath)
50 fs.writeFileSync(linkPath, data)
51 replaced++
52 return
53 }
54 // Replace symlink with a real recursive copy. Use `cp -r` (no -L)
55 // so nested symlinks inside the target stay as symlinks — they
56 // can resolve via the still-existing .pgh/ store.
57 fs.unlinkSync(linkPath)
58 try {
59 execSync('cp -r ' + JSON.stringify(real) + '/. ' + JSON.stringify(linkPath), { stdio: 'pipe' })
60 replaced++
61 } catch (err) {
62 console.error(' cp failed for', linkPath, '←', real, ':', err.message)
63 failed++
64 }
65}
66
67function scanLevel (dir) {
68 let entries
69 try { entries = fs.readdirSync(dir, { withFileTypes: true }) }
70 catch { return }
71 for (const entry of entries) {
72 if (entry.name === '.pgh') continue
73 const p = path.join(dir, entry.name)
74 if (entry.isSymbolicLink()) {
75 flattenLink(p)
76 } else if (entry.isDirectory() && entry.name.startsWith('@')) {
77 // scoped package dir — recurse one level for `@scope/<pkg>`
78 scanLevel(p)
79 }
80 }
81}
82
83console.log('flatten-node-modules: scanning ' + root + ' (top-level + scoped)')
84scanLevel(root)
85console.log(' replaced: ' + replaced + ' top-level symlinks')
86console.log(' failed: ' + failed)
87console.log(' elapsed: ' + ((Date.now() - start) / 1000).toFixed(1) + 's')
88console.log(' note: .pgh/ kept in place for nested-symlink fallback')
index.js +81 -1
……@@ -11,7 +11,27 @@ import path from 'path'
1111import os from 'os'
1212import process from 'process'
1313import Pipe from 'bare-pipe'
14import { startDaemon } from './daemon/daemon.js'
14
15// keet-daemon is loaded LAZILY (see loadKeetDaemon) — never as a top-level
16// import. A static `import './daemon/daemon.js'` is evaluated before main()
17// runs flattenIfNeeded(), so on a fresh pgh install (content-addressed
18// symlinked node_modules) keet-core's Bundlebee resolver can't find its
19// modules — it expects a flat npm layout, and Bare caches by realpath. The
20// daemon must be imported only AFTER node_modules is flattened.
21let startDaemon = null
22let _keetDaemonLoaded = false
23async function loadKeetDaemon () {
24 if (_keetDaemonLoaded) return startDaemon
25 _keetDaemonLoaded = true
26 try {
27 const mod = await import('./daemon/daemon.js')
28 startDaemon = mod.startDaemon
29 } catch (err) {
30 console.error('[launcher] keet-daemon module not loadable:', err.message)
31 if (err.code) console.error('[launcher] code:', err.code)
32 }
33 return startDaemon
34}
1535
1636const isPear = typeof Pear !== 'undefined'
1737const DAEMON_SOCKET = '/tmp/keet-daemon.sock'
……@@ -110,6 +130,13 @@ async function ensureDaemon () {
110130 // Remove stale socket if it exists
111131 try { fs.unlinkSync(DAEMON_SOCKET) } catch {}
112132
133 // Daemon module is loaded lazily after the flatten; if it failed to load
134 // there's nothing to start.
135 if (!startDaemon) {
136 console.error('[launcher] keet-daemon module not loaded — starting without daemon')
137 return null
138 }
139
113140 // Start daemon in-process
114141 console.log('[launcher] Starting keet-daemon (in-process)...')
115142 try {
……@@ -187,6 +214,53 @@ function startBackgroundUpdate () {
187214 }
188215}
189216
217// Replace top-level symlinks in node_modules with real copies. Idempotent —
218// no-op if already flat. Required because keet-core 4.x's Bundlebee resolver
219// expects a flat npm layout, while pgh's content-addressed materializer
220// installs node_modules/<pkg> as symlinks into node_modules/.pgh/<hash>/.
221// Without this, keet-daemon throws "failed to find module:
222// /node_modules/<pkg>/index.js" on first bundle load (room invite / chat send).
223// Mirrors the proven hyper-tps workaround.
224async function flattenIfNeeded () {
225 const nodeModulesDir = path.join(getAppDir(), 'node_modules')
226 if (!fs.existsSync(nodeModulesDir)) return
227
228 // Quick check: any top-level (or scoped) symlink left?
229 let needsFlatten = false
230 for (const name of fs.readdirSync(nodeModulesDir)) {
231 if (name === '.pgh') continue
232 const p = path.join(nodeModulesDir, name)
233 try {
234 const st = fs.lstatSync(p)
235 if (st.isSymbolicLink()) { needsFlatten = true; break }
236 if (name.startsWith('@') && st.isDirectory()) {
237 for (const sub of fs.readdirSync(p)) {
238 if (fs.lstatSync(path.join(p, sub)).isSymbolicLink()) { needsFlatten = true; break }
239 }
240 }
241 } catch {}
242 if (needsFlatten) break
243 }
244 if (!needsFlatten) {
245 console.log('[launcher] node_modules already flat — skipping flatten')
246 return
247 }
248
249 console.log('[launcher] Flattening node_modules (pgh symlinks → real copies)…')
250 const scriptPath = path.join(getAppDir(), 'flatten-node-modules.cjs')
251 if (!fs.existsSync(scriptPath)) {
252 console.error('[launcher] flatten script missing at', scriptPath)
253 return
254 }
255 await new Promise((resolve, reject) => {
256 const child = subprocess.spawn('node', [scriptPath, nodeModulesDir], { stdio: 'inherit' })
257 child.on('exit', code => code === 0 ? resolve() : reject(new Error('flatten exit ' + code)))
258 child.on('error', reject)
259 }).catch(err => {
260 console.error('[launcher] flatten failed (continuing anyway):', err.message)
261 })
262}
263
190264let daemonRef = null
191265
192266async function main () {
……@@ -201,6 +275,12 @@ async function main () {
201275 console.log(`[launcher] godot-keet ${appVersion}`)
202276 console.log(`[launcher] Platform: ${platformDir}`)
203277
278 // keet-core's Bundlebee needs a flat node_modules; pgh installs it as
279 // content-addressed symlinks. Flatten first, THEN load the daemon module
280 // (its keet-core import resolves only against the flat layout).
281 await flattenIfNeeded()
282 await loadKeetDaemon()
283
204284 // Start daemon in-process (or detect existing external daemon)
205285 daemonRef = await ensureDaemon()
206286