Split self-host server package

This commit is contained in:
Kirill Dubovitskiy
2026-05-21 22:46:33 -07:00
parent 4a64c66a4a
commit d2d2f73011
15 changed files with 414 additions and 155 deletions
+11 -35
View File
@@ -82,42 +82,18 @@ pnpm --filter happy run build
Report success/failure. Stop on failure.
### Step 5b: Rebuild bundled assets (server + webapp) — REQUIRED for every CLI release
### Step 5b: Self-host server split
**Do not skip this. `pnpm run build` does NOT rebuild `tools/`.** The npm package
ships `tools/` (see `files` in package.json) **as-is from disk**. The bundled
self-host server (`tools/server/<plat>/`) and web app (`tools/webapp/`) are
produced by separate scripts, so a plain build + publish ships **whatever stale
bundle happened to be on disk** — this is exactly how a beta went out with an old
server bundle and a broken Prisma engine.
The `happy` npm package no longer bundles the self-host server binary or webapp.
Packaged installs resolve those from the separately installed
`happy-server-self-host` package. Do not rebuild or ship `tools/server` or
`tools/webapp` as part of a CLI release.
```bash
# 1. Regenerate Prisma client before compiling the server.
# The native query engine is provided at install time by happy's
# @prisma/engines dependency; do not package all platform engines into tools/.
pnpm --filter happy-server generate
# 2. Cross-build the server binary for all 5 platforms (NOT host-only).
pnpm --filter happy run bundle:server:all
# 3. Rebuild the bundled web app.
pnpm --filter happy run bundle:webapp
```
Sanity-check the output before continuing — every platform dir under
`packages/happy-cli/tools/server/` must contain `happy-server`,
`pglite.wasm`, `pglite.data`, `prisma/migrations/`, and `tools/webapp/index.html`
must exist:
```bash
ls packages/happy-cli/tools/server/*/ && ls packages/happy-cli/tools/webapp/index.html
```
`bundle:server` needs `bun` on PATH. Cross-compiling all platforms from one host
is supported by `bun build --compile --target`. The Prisma query engine is a
native `.node` file and is NOT embeddable in the bun binary; `happy-cli` resolves
it from its `@prisma/engines` dependency and points Prisma at it via
`PRISMA_QUERY_ENGINE_LIBRARY`.
If the CLI release depends on self-host server changes, release
`happy-server-self-host` separately: regenerate Prisma, build the bundled webapp
with `pnpm --filter happy-server-self-host run bundle:webapp`, then publish the
server package. The server package is a JS/TS npm package; npm handles platform
specific dependencies such as Prisma and sharp normally.
### Step 6: Test (unit only)
@@ -368,7 +344,7 @@ Separate repo, not part of this monorepo. Guide the user to push to that repo.
- **Release notes: investigate with subagents, exclude default-off, ask when unsure** — see "Writing release notes" above.
- **Always present options** — never assume which component, channel, or version.
- **Always verify before publishing** — show the user what will be published and get confirmation.
- **Always rebuild bundled assets on a CLI release** — `pnpm --filter happy-server generate`, then `bundle:server:all` + `bundle:webapp` before pack/publish (Step 5b). `pnpm run build` does NOT do this; the tarball ships `tools/` from disk, so skipping it ships a stale/broken server + webapp.
- **Do not bundle self-host server/webapp into `happy`** — self-host runtime and the bundled webapp ship through `happy-server-self-host`, not the main CLI package.
- **Unit tests are the gate, not integration tests** — integration tests are slow and have flaky abort/interrupt tests.
- **Use pnpm publish, not npm publish** — avoids workspace protocol issues.
- **Use --ignore-scripts** — we build and test explicitly, no need for prepublishOnly to redo it.
+19 -18
View File
@@ -5,11 +5,13 @@ on:
branches: [ main ]
paths:
- 'packages/happy-cli/**'
- 'packages/happy-server/**'
- '.github/workflows/cli-smoke-test.yml'
pull_request:
branches: [ main ]
paths:
- 'packages/happy-cli/**'
- 'packages/happy-server/**'
- '.github/workflows/cli-smoke-test.yml'
workflow_dispatch:
@@ -39,25 +41,24 @@ jobs:
- name: Build package
run: pnpm --filter happy build
# `happy server` ships a bun-compiled binary under tools/server/.
# `pnpm build` does NOT produce it — without this step the packed tarball has
# no server bundle and `happy server` cannot run. The Prisma engine is
# resolved from happy's @prisma/engines dependency at install time.
- name: Setup Bun
uses: oven-sh/setup-bun@v2
- name: Generate Prisma client + bundle server (host platform)
# `happy server` resolves the self-host runtime and bundled webapp from
# happy-server-self-host. The server package is JS/TS and lets npm install
# platform-specific dependencies such as Prisma and sharp normally.
- name: Generate Prisma client + bundle happy-server-self-host webapp
run: |
pnpm --filter happy-server generate
pnpm --filter happy run bundle:server
pnpm --filter happy-server-self-host generate
pnpm --filter happy-server-self-host run bundle:webapp
- name: Pack package
run: pnpm --filter happy pack --pack-destination packages/happy-cli
- name: Install packed package globally
- name: Pack packages
run: |
PACKAGE_FILE=$(ls packages/happy-cli/*.tgz)
npm install -g "./$PACKAGE_FILE"
pnpm --filter happy pack --pack-destination packages/happy-cli
pnpm --filter happy-server-self-host pack --pack-destination packages/happy-server
- name: Install packed packages globally
run: |
HAPPY_PACKAGE_FILE=$(ls packages/happy-cli/*.tgz)
SERVER_PACKAGE_FILE=$(ls packages/happy-server/*.tgz)
npm install -g "./$SERVER_PACKAGE_FILE" "./$HAPPY_PACKAGE_FILE"
- name: Test binary execution
run: |
@@ -88,7 +89,7 @@ jobs:
echo "Binary smoke test passed on Linux!"
- name: Test bundled server (happy server)
- name: Test packaged server (happy server)
run: |
export HAPPY_HOME_DIR="$(mktemp -d)"
timeout 90s happy server --port 4505 --host 127.0.0.1 --no-persist --reset > /tmp/happy-server.log 2>&1 &
@@ -103,7 +104,7 @@ jobs:
BODY=$(curl -s -m 5 -G http://127.0.0.1:4505/v1/auth/request/status --data-urlencode "publicKey=$PK" || true)
echo "auth/request/status -> $BODY"
kill "$SERVER_PID" 2>/dev/null || true
pkill -f tools/server || true
pkill -f happy-server || true
echo "=== happy server log ==="; cat /tmp/happy-server.log || true
if [ "$UP" != "1" ]; then echo "Error: happy server did not become healthy"; exit 1; fi
if ! echo "$BODY" | grep -q '"status"'; then echo "Error: Prisma-backed endpoint did not respond"; exit 1; fi
+2 -2
View File
@@ -54,7 +54,8 @@
"dist",
"bin",
"scripts",
"tools",
"tools/archives",
"tools/licenses",
"package.json"
],
"scripts": {
@@ -77,7 +78,6 @@
"@noble/ed25519": "^3.0.0",
"@noble/hashes": "^2.0.1",
"@paralleldrive/cuid2": "^2.2.2",
"@prisma/engines": "6.19.2",
"@slopus/happy-wire": "workspace:*",
"@stablelib/base64": "^2.0.1",
"@stablelib/hex": "^2.0.1",
+28 -16
View File
@@ -1,26 +1,26 @@
#!/usr/bin/env node
/**
* Bundles happy-server into a self-contained artifact shipped inside happy-cli/tools/server/.
* Bundles happy-server into a self-contained artifact directory.
*
* Uses `bun build --compile` to produce a single platform-specific binary, then copies the
* pglite WASM/data files and prisma migrations alongside. happy-cli does NOT depend on the
* happy-server workspace package — we reach into the sibling directory at build time only.
*
* Layout produced:
* tools/server/
* <out-dir>/
* <platform>/
* happy-server # bun-compiled binary
* pglite.wasm # PGlite expects these next to process.execPath
* pglite.data
* prisma/migrations/...
*
* Prisma query engine: bun --compile cannot embed native .node modules.
* happy-cli depends on @prisma/engines and points PRISMA_QUERY_ENGINE_LIBRARY
* at the npm-installed engine for the current machine when spawning this binary.
* Prisma query engine: bun --compile cannot embed native .node modules. The package
* that runs this binary must provide @prisma/engines and point Prisma at the native
* library for the current machine via PRISMA_QUERY_ENGINE_LIBRARY.
*
* Default: builds for the current host platform only. Pass --all-platforms to cross-build
* for all six (used by release/CI).
* for every supported platform (used by release/CI).
*/
const fs = require('node:fs');
@@ -31,7 +31,6 @@ const { spawnSync } = require('node:child_process');
const PACKAGE_DIR = path.resolve(__dirname, '..');
const REPO_ROOT = path.resolve(PACKAGE_DIR, '..', '..');
const SERVER_DIR = path.resolve(REPO_ROOT, 'packages/happy-server');
const OUT_DIR = path.resolve(PACKAGE_DIR, 'tools/server');
const BUN_TARGETS = {
'arm64-darwin': 'bun-darwin-arm64',
@@ -78,13 +77,13 @@ function findPgliteAsset(name) {
return null;
}
function buildPlatform(plat) {
function buildPlatform(plat, outRoot) {
if (!BUN_TARGETS[plat]) {
console.error(`Unsupported platform: ${plat}`);
process.exit(1);
}
const target = BUN_TARGETS[plat];
const outDir = path.join(OUT_DIR, plat);
const outDir = path.join(outRoot, plat);
fs.mkdirSync(outDir, { recursive: true });
const outFile = path.join(outDir, platformBinaryName(plat));
@@ -111,8 +110,8 @@ function buildPlatform(plat) {
);
}
function copyAssetsForPlatform(plat) {
const platDir = path.join(OUT_DIR, plat);
function copyAssetsForPlatform(plat, outRoot) {
const platDir = path.join(outRoot, plat);
console.log(`\n→ Copying assets (pglite + migrations) into ${path.relative(PACKAGE_DIR, platDir)}`);
for (const asset of ['pglite.wasm', 'pglite.data']) {
@@ -135,22 +134,35 @@ function copyAssetsForPlatform(plat) {
function main() {
const args = process.argv.slice(2);
const allPlatforms = args.includes('--all-platforms');
const outDirArg = valueAfter(args, '--out-dir');
const outDir = outDirArg ? path.resolve(process.cwd(), outDirArg) : path.resolve(PACKAGE_DIR, 'tools/server');
if (!fs.existsSync(SERVER_DIR)) {
console.error(`Missing ${SERVER_DIR}. Run from the monorepo.`);
process.exit(1);
}
rmrf(OUT_DIR);
fs.mkdirSync(OUT_DIR, { recursive: true });
rmrf(outDir);
fs.mkdirSync(outDir, { recursive: true });
const targets = allPlatforms ? Object.keys(BUN_TARGETS) : [currentPlatform()];
for (const plat of targets) {
buildPlatform(plat);
copyAssetsForPlatform(plat);
buildPlatform(plat, outDir);
copyAssetsForPlatform(plat, outDir);
}
console.log(`\n✓ happy-server bundle written to ${OUT_DIR}`);
console.log(`\n✓ happy-server bundle written to ${outDir}`);
}
function valueAfter(args, flag) {
const idx = args.indexOf(flag);
if (idx === -1) return null;
const value = args[idx + 1];
if (!value || value.startsWith('--')) {
console.error(`Missing value for ${flag}`);
process.exit(1);
}
return value;
}
main();
+22 -8
View File
@@ -2,8 +2,8 @@
/**
* Runs `expo export -p web` in packages/happy-app and copies the output into
* happy-cli/tools/webapp/. happy-cli ships this directory so `happy server` can serve the
* web client statically alongside the API.
* a package-owned artifact directory. By default this writes to happy-cli/tools/webapp
* for local development. Release packaging can pass --out-dir to place it elsewhere.
*
* happy-cli does NOT depend on happy-app — we reach into the sibling at build time only.
*/
@@ -16,7 +16,6 @@ const PACKAGE_DIR = path.resolve(__dirname, '..');
const REPO_ROOT = path.resolve(PACKAGE_DIR, '..', '..');
const APP_DIR = path.resolve(REPO_ROOT, 'packages/happy-app');
const APP_DIST = path.join(APP_DIR, 'dist');
const OUT_DIR = path.resolve(PACKAGE_DIR, 'tools/webapp');
function rmrf(p) {
fs.rmSync(p, { recursive: true, force: true });
@@ -31,6 +30,10 @@ function run(cmd, args, opts = {}) {
}
function main() {
const args = process.argv.slice(2);
const outDirArg = valueAfter(args, '--out-dir');
const outDir = outDirArg ? path.resolve(process.cwd(), outDirArg) : path.resolve(PACKAGE_DIR, 'tools/webapp');
if (!fs.existsSync(APP_DIR)) {
console.error(`Missing ${APP_DIR}. Run from the monorepo.`);
process.exit(1);
@@ -45,12 +48,23 @@ function main() {
process.exit(1);
}
console.log(`\n→ Copying webapp into ${OUT_DIR}`);
rmrf(OUT_DIR);
fs.mkdirSync(path.dirname(OUT_DIR), { recursive: true });
fs.cpSync(APP_DIST, OUT_DIR, { recursive: true });
console.log(`\n→ Copying webapp into ${outDir}`);
rmrf(outDir);
fs.mkdirSync(path.dirname(outDir), { recursive: true });
fs.cpSync(APP_DIST, outDir, { recursive: true });
console.log(`\n✓ webapp written to ${OUT_DIR}`);
console.log(`\n✓ webapp written to ${outDir}`);
}
function valueAfter(args, flag) {
const idx = args.indexOf(flag);
if (idx === -1) return null;
const value = args[idx + 1];
if (!value || value.startsWith('--')) {
console.error(`Missing value for ${flag}`);
process.exit(1);
}
return value;
}
main();
+115 -16
View File
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { randomBytes } from 'node:crypto';
import { spawn, type ChildProcess } from 'node:child_process';
import { createInterface } from 'node:readline/promises';
import { configuration } from '@/configuration';
import { updateSettings } from '@/persistence';
@@ -19,12 +20,15 @@ const PRISMA_QUERY_ENGINE_FILES: Record<string, string> = {
'x64-linux': 'libquery_engine-debian-openssl-3.0.x.so.node',
'x64-win32': 'query_engine-windows.dll.node',
};
const SERVER_PACKAGE_NAME = 'happy-server-self-host';
const SETTINGS_WRITE_CONFIRM_FLAG = '--i-understand-this-will-modify-default-happy-settings';
interface ServerOptions {
port: number;
host: string;
reset: boolean;
persistServerUrl: boolean;
allowSettingsWrite: boolean;
masterSecret?: string;
}
@@ -37,12 +41,31 @@ interface ServerArtifacts {
cwd: string;
/** True when running the bundled bun binary; false when running from monorepo source. */
bundled: boolean;
/** Where this runnable came from. */
source: 'package' | 'legacy-bundled' | 'source';
/** Prisma's native query engine path for bun-compiled server binaries. */
prismaQueryEngineLibrary?: string;
/** Static web app directory served by the self-host server. */
webappDir?: string;
}
interface HappyServerPackageArtifact {
command: string;
prefixArgs?: string[];
cwd: string;
bundled?: boolean;
source?: string;
prismaQueryEngineLibrary?: string;
webappDir?: string;
}
export async function handleServerCommand(args: string[]): Promise<void> {
const opts = parseArgs(args);
if (opts === null) return;
const serverUrl = `http://${opts.host === '0.0.0.0' ? '127.0.0.1' : opts.host}:${opts.port}`;
await ensureSettingsWriteAllowed(opts, serverUrl);
const dataDir = path.join(configuration.happyHomeDir, 'server-data');
const pgliteDir = path.join(dataDir, 'pglite');
const secretFile = path.join(dataDir, 'master-secret');
@@ -58,20 +81,21 @@ export async function handleServerCommand(args: string[]): Promise<void> {
const artifacts = resolveServerArtifacts();
if (!artifacts) {
console.error(chalk.red('Could not locate the happy-server bundle or source.'));
console.error(chalk.red('Could not locate happy-server.'));
console.error(chalk.gray(' Expected one of:'));
console.error(chalk.gray(` - bundled binary at ${path.join(__dirname, '..', '..', 'tools', 'server', currentPlatform(), bundledBinaryName())}`));
console.error(chalk.gray(` - installed ${SERVER_PACKAGE_NAME} package`));
console.error(chalk.gray(` - legacy bundled binary at ${path.join(__dirname, '..', '..', 'tools', 'server', currentPlatform(), bundledBinaryName())}`));
console.error(chalk.gray(' - sibling packages/happy-server/sources/standalone.ts in the monorepo'));
console.error(chalk.gray(` For npm installs, run: npm install -g ${SERVER_PACKAGE_NAME}`));
process.exit(1);
}
const serverUrl = `http://${opts.host === '0.0.0.0' ? '127.0.0.1' : opts.host}:${opts.port}`;
const staticDir = findWebappDir();
const staticDir = artifacts.webappDir ?? findWebappDir();
console.log(chalk.cyan(`\n happy server`));
console.log(chalk.gray(` data dir: ${dataDir}`));
console.log(chalk.gray(` server url: ${serverUrl}`));
console.log(chalk.gray(` mode: ${artifacts.bundled ? 'bundled' : 'source (dev)'}`));
console.log(chalk.gray(` mode: ${serverArtifactMode(artifacts)}`));
if (staticDir) {
console.log(chalk.gray(` webapp: ${staticDir}`));
} else {
@@ -98,11 +122,16 @@ export async function handleServerCommand(args: string[]): Promise<void> {
// mode resolves the engine from node_modules normally, but bundled mode needs
// an explicit path because bun's bunfs execPath defeats Prisma's search.
if (artifacts.bundled) {
const prismaEngine = resolvePrismaQueryEngineLibrary(artifacts.cwd);
const prismaEngine = artifacts.prismaQueryEngineLibrary ?? resolvePrismaQueryEngineLibrary(artifacts.cwd);
if (!prismaEngine) {
console.error(chalk.red('Could not locate the Prisma query engine for this platform.'));
console.error(chalk.gray(' Expected @prisma/engines to be installed with the happy package.'));
console.error(chalk.gray(' Try reinstalling happy, then run `happy server` again.'));
if (artifacts.source === 'package') {
console.error(chalk.gray(` Expected ${SERVER_PACKAGE_NAME} to install @prisma/engines.`));
console.error(chalk.gray(` Try reinstalling ${SERVER_PACKAGE_NAME}, then run \`happy server\` again.`));
} else {
console.error(chalk.gray(' Expected @prisma/engines to be available near the happy package.'));
console.error(chalk.gray(' Try reinstalling happy, then run `happy server` again.'));
}
process.exit(1);
}
env.PRISMA_QUERY_ENGINE_LIBRARY = prismaEngine;
@@ -138,13 +167,13 @@ export async function handleServerCommand(args: string[]): Promise<void> {
process.on('SIGINT', () => forwardSignal('SIGINT'));
process.on('SIGTERM', () => forwardSignal('SIGTERM'));
await new Promise<void>(resolve => {
const exitCode = await new Promise<number>(resolve => {
child.on('exit', code => {
console.log(chalk.gray(`\nhappy-server exited (code ${code ?? 0})`));
resolve();
resolve(code ?? 0);
});
});
process.exit(0);
process.exit(exitCode);
}
function parseArgs(args: string[]): ServerOptions | null {
@@ -152,6 +181,7 @@ function parseArgs(args: string[]): ServerOptions | null {
let host = '127.0.0.1';
let reset = false;
let persistServerUrl = true;
let allowSettingsWrite = false;
let masterSecret: string | undefined;
for (let i = 0; i < args.length; i++) {
@@ -171,6 +201,8 @@ function parseArgs(args: string[]): ServerOptions | null {
reset = true;
} else if (arg === '--no-persist') {
persistServerUrl = false;
} else if (arg === SETTINGS_WRITE_CONFIRM_FLAG) {
allowSettingsWrite = true;
} else if (arg === '--master-secret') {
masterSecret = args[++i];
} else {
@@ -180,7 +212,7 @@ function parseArgs(args: string[]): ServerOptions | null {
}
}
return { port, host, reset, persistServerUrl, masterSecret };
return { port, host, reset, persistServerUrl, allowSettingsWrite, masterSecret };
}
function showHelp() {
@@ -195,15 +227,48 @@ ${chalk.bold('Options:')}
--host <ip> Host to bind (default: 127.0.0.1)
--reset Wipe local server data before starting
--no-persist Don't write serverUrl into settings.json
${SETTINGS_WRITE_CONFIRM_FLAG}
Write settings.serverUrl/settings.webappUrl without prompting
--master-secret <hex> Use a specific master secret (default: auto-generated)
${chalk.bold('Notes:')}
- Stores data in ${chalk.cyan('$HAPPY_HOME_DIR/server-data/')}
- Writes ${chalk.cyan('settings.serverUrl')} so happy CLI + daemon point at it automatically
- Packaged installs require ${chalk.cyan(SERVER_PACKAGE_NAME)} for the local server binary
- By default, asks before writing ${chalk.cyan('settings.serverUrl')} and ${chalk.cyan('settings.webappUrl')}
- Use ${chalk.cyan('--no-persist')} to run without modifying default Happy settings
- Open ${chalk.cyan('http://127.0.0.1:<port>')} for the web app (if bundled)
`);
}
async function ensureSettingsWriteAllowed(opts: ServerOptions, serverUrl: string): Promise<void> {
if (!opts.persistServerUrl || opts.allowSettingsWrite) {
return;
}
const message =
`happy server will write settings.serverUrl and settings.webappUrl to ${serverUrl} ` +
`in ${configuration.settingsFile}.`;
if (!process.stdin.isTTY || !process.stderr.isTTY) {
console.error(chalk.red('Refusing to modify default Happy settings from a non-interactive run.'));
console.error(chalk.gray(message));
console.error(chalk.gray(`Re-run with --no-persist, or pass ${SETTINGS_WRITE_CONFIRM_FLAG}.`));
process.exit(1);
}
const rl = createInterface({ input: process.stdin, output: process.stderr });
try {
const answer = await rl.question(`${chalk.yellow(message)} Continue? ${chalk.gray('[y/N]')} `);
const normalized = answer.trim().toLowerCase();
if (normalized !== 'y' && normalized !== 'yes') {
console.error(chalk.gray('Cancelled. Re-run with --no-persist to start without changing settings.'));
process.exit(1);
}
} finally {
rl.close();
}
}
function loadOrCreateMasterSecret(file: string): string {
if (existsSync(file)) {
return readFileSync(file, 'utf8').trim();
@@ -280,20 +345,30 @@ function ensureExecutable(file: string): void {
}
}
function serverArtifactMode(artifacts: ServerArtifacts): string {
if (artifacts.source === 'package') return SERVER_PACKAGE_NAME;
if (artifacts.source === 'legacy-bundled') return 'legacy bundled';
return 'source (dev)';
}
/**
* Resolves the artifacts needed to spawn happy-server.
*
* Order:
* 1. Bundled binary at tools/server/<platform>/happy-server (shipped with npm package)
* 2. Source-mode fallback for monorepo dev: ../happy-server/sources/standalone.ts via tsx
* 1. happy-server-self-host package (npm-installed local server artifact)
* 2. Legacy bundled binary at tools/server/<platform>/happy-server
* 3. Source-mode fallback for monorepo dev: ../happy-server/sources/standalone.ts via tsx
*/
function resolveServerArtifacts(): ServerArtifacts | undefined {
const packageArtifact = resolveInstalledServerPackage();
if (packageArtifact) return packageArtifact;
const toolsRoot = resolveToolsPath('server');
const binDir = path.join(toolsRoot, currentPlatform());
const binary = path.join(binDir, bundledBinaryName());
if (existsSync(binary)) {
ensureExecutable(binary);
return { command: binary, prefixArgs: [], cwd: binDir, bundled: true };
return { command: binary, prefixArgs: [], cwd: binDir, bundled: true, source: 'legacy-bundled' };
}
const sourceEntry = findSourceStandalone();
@@ -305,12 +380,36 @@ function resolveServerArtifacts(): ServerArtifacts | undefined {
prefixArgs: useNode ? [tsx, sourceEntry] : [sourceEntry],
cwd: path.dirname(path.dirname(sourceEntry)),
bundled: false,
source: 'source',
};
}
return undefined;
}
function resolveInstalledServerPackage(): ServerArtifacts | undefined {
try {
const serverPackage = require_(SERVER_PACKAGE_NAME) as {
resolveServerArtifact?: () => HappyServerPackageArtifact | undefined;
};
const artifact = serverPackage.resolveServerArtifact?.();
if (!artifact || !artifact.command || !existsSync(artifact.command)) {
return undefined;
}
return {
command: artifact.command,
prefixArgs: artifact.prefixArgs ?? [],
cwd: artifact.cwd,
bundled: artifact.bundled ?? true,
source: 'package',
prismaQueryEngineLibrary: artifact.prismaQueryEngineLibrary,
webappDir: artifact.webappDir,
};
} catch {
return undefined;
}
}
function findSourceStandalone(): string | undefined {
const candidates = [
path.resolve(__dirname, '../../../happy-server/sources/standalone.ts'),
+3 -17
View File
@@ -1,18 +1,4 @@
node_modules
.env
dist
.pgdata
.minio
.env.local
.env
.logs/
.claude/
# Standalone build artifacts
webapp/
data/
happy-server
pglite.wasm
pglite.data
.logs/
*.tgz
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env node
'use strict';
const { spawn } = require('node:child_process');
const { resolveServerArtifact } = require('../index.cjs');
const artifact = resolveServerArtifact();
if (!artifact) {
console.error('Could not locate the Happy server package runtime.');
process.exit(1);
}
const env = { ...process.env };
if (artifact.webappDir && !env.HAPPY_STATIC_DIR) {
env.HAPPY_STATIC_DIR = artifact.webappDir;
}
const child = spawn(artifact.command, [...artifact.prefixArgs, ...process.argv.slice(2)], {
cwd: artifact.cwd,
env,
stdio: 'inherit',
});
child.on('error', error => {
console.error(error.message);
process.exit(1);
});
child.on('exit', (code, signal) => {
if (signal) process.kill(process.pid, signal);
process.exit(code ?? 0);
});
+55
View File
@@ -0,0 +1,55 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { createRequire } = require('node:module');
const require_ = createRequire(__filename);
function packageRoot() {
return __dirname;
}
function getWebappDirectory() {
return path.join(packageRoot(), 'webapp');
}
function findTsxCli() {
return require_.resolve('tsx/cli', { paths: [packageRoot()] });
}
function resolveServerArtifact() {
const runtime = path.join(packageRoot(), 'dist', 'standalone.mjs');
if (fs.existsSync(runtime)) {
const webappDir = getWebappDirectory();
return {
command: process.execPath,
prefixArgs: [runtime],
cwd: packageRoot(),
bundled: false,
source: 'package',
platform: `${process.arch}-${process.platform}`,
webappDir: fs.existsSync(path.join(webappDir, 'index.html')) ? webappDir : undefined,
};
}
const standalone = path.join(packageRoot(), 'sources', 'standalone.ts');
if (!fs.existsSync(standalone)) return undefined;
const webappDir = getWebappDirectory();
return {
command: process.execPath,
prefixArgs: [findTsxCli(), standalone],
cwd: packageRoot(),
bundled: false,
source: 'package',
platform: `${process.arch}-${process.platform}`,
webappDir: fs.existsSync(path.join(webappDir, 'index.html')) ? webappDir : undefined,
};
}
module.exports = {
packageRoot,
getWebappDirectory,
resolveServerArtifact,
};
+45 -21
View File
@@ -1,18 +1,38 @@
{
"name": "happy-server",
"version": "0.0.0",
"repository": "https://github.com/slopus/happy-server.git",
"author": "Steve Korshakov <steve@korshakov.com>",
"license": "MIT",
"private": true,
"type": "module",
"main": "./sources/index.ts",
"exports": {
".": "./sources/index.ts",
"./standalone": "./sources/standalone.ts"
"name": "happy-server-self-host",
"version": "1.1.10",
"description": "Happy self-host server and bundled web app",
"repository": {
"type": "git",
"url": "https://github.com/slopus/happy"
},
"homepage": "https://happy.engineering",
"bugs": "https://github.com/slopus/happy/issues",
"author": "Kirill Dubovitskiy",
"license": "MIT",
"type": "module",
"bin": {
"happy-server": "./bin/happy-server.cjs"
},
"main": "./index.cjs",
"exports": {
".": "./index.cjs",
"./standalone": "./dist/standalone.mjs",
"./package.json": "./package.json"
},
"files": [
"bin",
"index.cjs",
"dist",
"prisma",
"webapp",
"package.json",
"README.md"
],
"scripts": {
"build": "tsc --noEmit",
"build": "tsc --noEmit && node scripts/build-runtime.cjs",
"build:runtime": "node scripts/build-runtime.cjs",
"bundle:webapp": "node ../happy-cli/scripts/bundle-webapp.cjs --out-dir webapp",
"build:standalone": "bun build ./sources/standalone.ts --compile --outfile dist/happy-server --target bun && find ../../node_modules/@electric-sql/pglite/dist -name 'pglite.wasm' -exec cp {} dist/ \\; && find ../../node_modules/@electric-sql/pglite/dist -name 'pglite.data' -exec cp {} dist/ \\; && cp -r prisma/migrations dist/prisma/migrations",
"start": "tsx ./sources/main.ts",
"standalone": "tsx ./sources/standalone.ts",
@@ -21,8 +41,8 @@
"test": "vitest run",
"migrate": "dotenv -e .env.dev -- prisma migrate dev",
"migrate:reset": "dotenv -e .env.dev -- prisma migrate reset",
"generate": "prisma generate",
"postinstall": "prisma generate",
"generate": "prisma generate --schema=prisma/schema.prisma",
"postinstall": "prisma generate --schema=prisma/schema.prisma",
"db": "docker run -d -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=handy -v $(pwd)/.pgdata:/var/lib/postgresql/data -p 5432:5432 postgres",
"redis": "docker run -d -p 6379:6379 redis",
"s3": "docker run -d --name minio -p 9000:9000 -p 9001:9001 -e MINIO_ROOT_USER=minioadmin -e MINIO_ROOT_PASSWORD=minioadmin -v $(pwd)/.minio/data:/data minio/minio server /data --console-address :9001",
@@ -32,12 +52,17 @@
"devDependencies": {
"@types/chalk": "^2.2.0",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.10",
"@types/node": "^20.12.3",
"@types/semver": "^7.7.0",
"@types/tmp": "^0.2.6",
"@types/uuid": "^9.0.8",
"dotenv-cli": "^8.0.0",
"ts-node": "^10.9.2",
"tsx": "^4.20.6",
"typescript": "5.9.3",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.0",
"yaml": "^2.4.2"
},
"dependencies": {
@@ -46,11 +71,9 @@
"@fastify/bearer-auth": "^10.1.1",
"@fastify/cors": "^10.0.1",
"@fastify/static": "^8.1.1",
"@prisma/client": "^6.11.1",
"@prisma/client": "6.19.2",
"@slopus/happy-wire": "workspace:*",
"@socket.io/redis-streams-adapter": "^0.2.2",
"@types/jsonwebtoken": "^9.0.10",
"@types/semver": "^7.7.0",
"axios": "^1.6.8",
"chalk": "4.1.2",
"date-fns": "^4.1.0",
@@ -65,7 +88,7 @@
"pglite-prisma-adapter": "^0.7.2",
"pino": "^10.3.0",
"pino-pretty": "^13.0.0",
"prisma": "^6.11.1",
"prisma": "6.19.2",
"prisma-json-types-generator": "^3.5.1",
"privacy-kit": "^0.0.25",
"prom-client": "^15.1.3",
@@ -75,12 +98,13 @@
"socket.io": "^4.8.1",
"socket.io-adapter": "^2.5.5",
"tmp": "^0.2.3",
"tsx": "^4.19.2",
"tweetnacl": "^1.0.3",
"uuid": "^9.0.1",
"vite-tsconfig-paths": "^5.1.4",
"vitest": "^3.2.0",
"zod": "3.25.76",
"zod-to-json-schema": "^3.24.3"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
}
}
@@ -0,0 +1,45 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const root = path.resolve(__dirname, '..');
const pkg = require(path.join(root, 'package.json'));
const dist = path.join(root, 'dist');
fs.rmSync(dist, { recursive: true, force: true });
fs.mkdirSync(dist, { recursive: true });
const args = [
'build',
'./sources/standalone.ts',
'--target',
'node',
'--format',
'esm',
'--outfile',
'dist/standalone.mjs',
];
const bundledDependencies = new Set([
// The published 0.1.0 package does not include the newest voice schemas yet.
// Keep the server release unblocked by bundling the workspace copy.
'@slopus/happy-wire',
]);
for (const dependency of Object.keys(pkg.dependencies ?? {})) {
if (bundledDependencies.has(dependency)) continue;
args.push('--external', dependency);
}
const result = spawnSync('bun', args, {
cwd: root,
stdio: 'inherit',
});
if (result.error) {
throw result.error;
}
process.exit(result.status ?? 1);
+18 -1
View File
@@ -120,7 +120,7 @@ async function serve() {
const port = process.env.PORT ? parseInt(process.env.PORT, 10) : 3005;
const host = process.env.HOST || "0.0.0.0";
const staticDir = process.env.HAPPY_STATIC_DIR || undefined;
const staticDir = findStaticDir();
let injectHtmlConfig: Record<string, unknown> | undefined;
if (process.env.HAPPY_INJECT_HTML_CONFIG) {
try {
@@ -143,6 +143,23 @@ async function serve() {
// Block until shutdown so the process stays alive.
const { awaitShutdown } = await import("./utils/shutdown");
await awaitShutdown();
process.exit(0);
}
function findStaticDir(): string | undefined {
const candidates = [
process.env.HAPPY_STATIC_DIR,
path.join(process.cwd(), "webapp"),
path.join(path.dirname(process.execPath), "webapp"),
].filter(Boolean) as string[];
for (const candidate of candidates) {
if (fs.existsSync(path.join(candidate, "index.html"))) {
return candidate;
}
}
return undefined;
}
// CLI — only when this file is invoked directly, not when imported as a library.
+1 -1
View File
@@ -45,7 +45,7 @@
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
"baseUrl": ".", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
+1
View File
@@ -48,6 +48,7 @@
"vitest": "^3.2.4"
},
"publishConfig": {
"access": "public",
"registry": "https://registry.npmjs.org"
},
"packageManager": "pnpm@10.11.0"
+17 -20
View File
@@ -927,9 +927,6 @@ importers:
'@paralleldrive/cuid2':
specifier: ^2.2.2
version: 2.3.1
'@prisma/engines':
specifier: 6.19.2
version: 6.19.2
'@slopus/happy-wire':
specifier: workspace:*
version: link:../happy-wire
@@ -1079,7 +1076,7 @@ importers:
specifier: ^8.1.1
version: 8.3.0
'@prisma/client':
specifier: ^6.11.1
specifier: 6.19.2
version: 6.19.2(prisma@6.19.2(typescript@5.9.3))(typescript@5.9.3)
'@slopus/happy-wire':
specifier: workspace:*
@@ -1087,12 +1084,6 @@ importers:
'@socket.io/redis-streams-adapter':
specifier: ^0.2.2
version: 0.2.3(socket.io-adapter@2.5.6)
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
'@types/semver':
specifier: ^7.7.0
version: 7.7.1
axios:
specifier: ^1.6.8
version: 1.13.4
@@ -1136,7 +1127,7 @@ importers:
specifier: ^13.0.0
version: 13.1.3
prisma:
specifier: ^6.11.1
specifier: 6.19.2
version: 6.19.2(typescript@5.9.3)
prisma-json-types-generator:
specifier: ^3.5.1
@@ -1165,21 +1156,12 @@ importers:
tmp:
specifier: ^0.2.3
version: 0.2.5
tsx:
specifier: ^4.19.2
version: 4.21.0
tweetnacl:
specifier: ^1.0.3
version: 1.0.3
uuid:
specifier: ^9.0.1
version: 9.0.1
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@8.0.9(@types/node@20.19.39)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: ^3.2.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
zod:
specifier: 3.25.76
version: 3.25.76
@@ -1193,9 +1175,15 @@ importers:
'@types/express':
specifier: ^4.17.21
version: 4.17.25
'@types/jsonwebtoken':
specifier: ^9.0.10
version: 9.0.10
'@types/node':
specifier: ^20.12.3
version: 20.19.39
'@types/semver':
specifier: ^7.7.0
version: 7.7.1
'@types/tmp':
specifier: ^0.2.6
version: 0.2.6
@@ -1208,9 +1196,18 @@ importers:
ts-node:
specifier: ^10.9.2
version: 10.9.2(@types/node@20.19.39)(typescript@5.9.3)
tsx:
specifier: ^4.20.6
version: 4.21.0
typescript:
specifier: 5.9.3
version: 5.9.3
vite-tsconfig-paths:
specifier: ^5.1.4
version: 5.1.4(typescript@5.9.3)(vite@8.0.9(@types/node@20.19.39)(esbuild@0.27.2)(jiti@2.6.1)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2))
vitest:
specifier: ^3.2.0
version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.39)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2)
yaml:
specifier: ^2.4.2
version: 2.8.2