From 288dd0052887b5c380502e7d4be184b045a42e99 Mon Sep 17 00:00:00 2001 From: Melvin Carvalho Date: Sun, 24 May 2026 11:41:03 +0200 Subject: [PATCH] =?UTF-8?q?feat(start):=20in-process=20mode=20=E2=80=94=20?= =?UTF-8?q?run=20JSS=20via=20createServer,=20no=20jss=20spawn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `start({ inProcess: true })`: maps the same options to javascript-solid-server's createServer() and listens in the current process instead of spawning the `jss` binary. Lets jspod embed in single-process runtimes (nodejs-mobile, Electron, tests) where forking a CLI isn't possible, while still returning the same handle shape (url/port/host/root/ready/exit/stop, plus the Fastify instance). Post-readiness logic (seedPodFiles + bootstrap + open) is factored into a shared afterReady() used by both paths, so the in-process pod gets the same jspod onboarding pages. Note: bootstrap still installs apps via git clone+push, which needs git + child-process spawning — unavailable in git-less runtimes (Android). There the 'error' handler fires harmlessly and the embedder seeds apps itself; a git-less install is a follow-up. Refs #58. --- lib/start.js | 155 +++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 126 insertions(+), 29 deletions(-) diff --git a/lib/start.js b/lib/start.js index bfa9b53..0b45b8f 100644 --- a/lib/start.js +++ b/lib/start.js @@ -167,6 +167,119 @@ function seedPodFiles(root) { } } +// Post-readiness steps shared by the spawn and in-process start paths: +// seed jspod-owned pages, optionally bootstrap the default app bundle on +// first run, and optionally open a browser. All best-effort — the pod is +// already serving by the time this runs. +function afterReady(options, url) { + const { appsDirExisted } = seedPodFiles(options.root); + if (!appsDirExisted && options.bootstrap && options.auth) { + console.log(chalk.bold.white(`\nšŸ“¦ First run — installing the `) + + chalk.yellow('default') + + chalk.bold.white(` bundle...\n`)); + const podUrl = url.replace(/\/$/, ''); + // Bootstrap installs apps via git clone+push (see index.js). That needs + // a git binary and the ability to spawn a child — unavailable in + // git-less single-process runtimes (e.g. nodejs-mobile), where the + // 'error' handler below fires and the embedder is expected to seed apps + // its own way. On a normal host it just works. + const installChild = spawn( + process.execPath, + [CLI_SCRIPT, 'install', '--pod', podUrl, '--bundle', 'default'], + { stdio: 'inherit' } + ); + installChild.on('exit', (code) => { + if (code !== 0) { + console.error(chalk.red(`\nāœ— Bootstrap exited with code ${code}.`)); + console.error(chalk.dim(' Install apps manually with `jspod install`.')); + } + }); + installChild.on('error', (e) => { + console.error(chalk.red(`\nāœ— Bootstrap failed to start: ${e.message}`)); + }); + } + if (options.open && shouldAutoOpen()) { + console.log(chalk.green(`\n🌐 Opening ${url} in your browser...`)); + openInBrowser(url); + } +} + +/** + * In-process variant of start(): boots JSS via createServer() in the current + * process (no `jss` spawn) and returns the same handle shape. Lets jspod embed + * in single-process runtimes (nodejs-mobile, Electron, tests). Port/root prep + * is already done by start() before this is called. + */ +async function startInProcess(options, { tokenSecret, dataBrowserUrl, RUNG_1_PASSWORD }) { + const url = formatUrl(options.host, options.port); + + // JSS reads TOKEN_SECRET from the environment. + process.env.TOKEN_SECRET = tokenSecret; + if (!process.env.NODE_ENV) process.env.NODE_ENV = 'development'; + + const { createServer } = await import('javascript-solid-server/src/server.js'); + + const serverOpts = { + root: options.root, + port: options.port, + host: options.host, + conneg: true, + notifications: true, + git: options.git, + mashlibModule: dataBrowserUrl, + provisionKeys: !!options.provisionKeys, + // JSS forces a trailing slash on the discovery-doc issuer but emits the + // RFC 9207 `iss` param raw; pass the slash form so strict clients + // (solid-oidc) match byte-for-byte. + idpIssuer: url.endsWith('/') ? url : `${url}/` + }; + if (options.multiuser) { + serverOpts.idp = options.auth; + if (!options.auth) serverOpts.public = true; + } else { + serverOpts.singleUser = true; + if (options.auth) { + serverOpts.idp = true; + serverOpts.singleUserPassword = RUNG_1_PASSWORD; + } else { + serverOpts.public = true; + } + } + + const server = createServer(serverOpts); + await server.listen({ port: options.port, host: options.host }); + + let resolveExit; + const exit = new Promise((resolve) => { resolveExit = resolve; }); + + async function stop() { + try { await server.close(); } catch { /* already closed */ } + const result = { code: 0, signal: null }; + resolveExit(result); + return result; + } + + const ready = (async () => { + const ok = await waitForReady(url); + if (!ok) throw new Error(`jspod did not become ready at ${url} within 30s`); + afterReady(options, url); + return true; + })(); + ready.catch(() => {}); + + return { + url, + port: options.port, + host: options.host, + root: options.root, + ready, + exit, + stop, + inProcess: true, + server // the underlying Fastify instance, for advanced embedders + }; +} + const DEFAULTS = { port: 5444, host: 'localhost', @@ -178,7 +291,9 @@ const DEFAULTS = { browser: 'folder', provisionKeys: false, mcp: false, - bootstrap: true + bootstrap: true, + inProcess: false // run JSS in this process (no `jss` spawn) for + // single-process runtimes (nodejs-mobile, Electron, tests) }; /** @@ -234,6 +349,15 @@ export async function start(userOptions = {}) { const RUNG_1_PASSWORD = process.env.JSS_SINGLE_USER_PASSWORD || 'me'; const RUNG_1_PASSWORD_FROM_ENV = !!process.env.JSS_SINGLE_USER_PASSWORD; + // In-process mode: run JSS in this process via createServer() instead of + // spawning the `jss` binary, so jspod can embed in single-process runtimes + // (nodejs-mobile, Electron, tests) where forking a CLI isn't possible. + if (options.inProcess) { + return startInProcess(options, { + tokenSecret, dataBrowserUrl, RUNG_1_PASSWORD + }); + } + const jssArgs = [ 'start', '--port', String(options.port), @@ -288,34 +412,7 @@ export async function start(userOptions = {}) { const ok = await waitForReady(url); if (spawnError) throw spawnError; if (!ok) throw new Error(`jspod did not become ready at ${url} within 30s`); - // Post-readiness: seed pod files and (optionally) bootstrap the - // default app bundle on first run. Failures here are best-effort - // — the pod is already running. - const { appsDirExisted } = seedPodFiles(options.root); - if (!appsDirExisted && options.bootstrap && options.auth) { - console.log(chalk.bold.white(`\nšŸ“¦ First run — installing the `) + - chalk.yellow('default') + - chalk.bold.white(` bundle...\n`)); - const podUrl = url.replace(/\/$/, ''); - const installChild = spawn( - process.execPath, - [CLI_SCRIPT, 'install', '--pod', podUrl, '--bundle', 'default'], - { stdio: 'inherit' } - ); - installChild.on('exit', (code) => { - if (code !== 0) { - console.error(chalk.red(`\nāœ— Bootstrap exited with code ${code}.`)); - console.error(chalk.dim(' Install apps manually with `jspod install`.')); - } - }); - installChild.on('error', (e) => { - console.error(chalk.red(`\nāœ— Bootstrap failed to start: ${e.message}`)); - }); - } - if (options.open && shouldAutoOpen()) { - console.log(chalk.green(`\n🌐 Opening ${url} in your browser...`)); - openInBrowser(url); - } + afterReady(options, url); return true; })();