Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
249 changes: 3 additions & 246 deletions bun.lock

Large diffs are not rendered by default.

28 changes: 28 additions & 0 deletions packages/cli/src/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
ensureProjectConfig,
syncManagedDriverDependencies,
prepareProjectDiscovery,
renderFrameworkRunner,
writeTextFile,
} from './project'
import { hasProjectDependency } from './package-json'
import type {
Expand Down Expand Up @@ -137,6 +139,7 @@ export async function runProjectDependencyInstall(
export async function runProjectPrepare(projectRoot: string, io?: IoStreams): Promise<void> {
const project = await ensureProjectConfig(projectRoot)
await prepareProjectDiscovery(projectRoot, project.config)
await refreshFrameworkRunner(projectRoot)

await runNuxtPrepare(projectRoot)
await runSvelteKitSync(projectRoot)
Expand All @@ -145,11 +148,36 @@ export async function runProjectPrepare(projectRoot: string, io?: IoStreams): Pr
if (updatedDependencies && io) {
await runProjectDependencyInstall(io, projectRoot)
await prepareProjectDiscovery(projectRoot, project.config)
await refreshFrameworkRunner(projectRoot)
await runNuxtPrepare(projectRoot)
await runSvelteKitSync(projectRoot)
}
}

async function refreshFrameworkRunner(projectRoot: string): Promise<void> {
const frameworkProjectPath = resolve(projectRoot, '.holo-js/framework/project.json')
const frameworkRunnerPath = resolve(projectRoot, '.holo-js/framework/run.mjs')

try {
const content = await readFile(frameworkProjectPath, 'utf8')
const manifest = JSON.parse(content) as { framework?: unknown }

if (
manifest.framework !== 'next'
&& manifest.framework !== 'nuxt'
&& manifest.framework !== 'sveltekit'
) {
return
}

await writeTextFile(frameworkRunnerPath, renderFrameworkRunner({
framework: manifest.framework,
}))
} catch {
return
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

async function runNuxtPrepare(projectRoot: string): Promise<void> {
const frameworkProjectPath = resolve(projectRoot, '.holo-js/framework/project.json')
try {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export {
makeProjectRelativePath,
prepareProjectDiscovery,
readTextFile,
renderFrameworkRunner,
resolveDefaultArtifactPath,
resolveGeneratedSchemaPath,
resolveProjectPackageImportSpecifier,
Expand Down
83 changes: 66 additions & 17 deletions packages/cli/src/project/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -261,32 +261,58 @@ const SVELTE_HOOKS_OVERRIDE_BLOCK = [
' },',
].join('\n')

function svelteConfigHasHooksOverride(contents: string): boolean {
function getSvelteConfigHooksOverrideState(contents: string): 'managed' | 'custom' | 'none' {
if (contents.includes('.holo-js/generated/hooks')) {
return true
return 'managed'
}

// Detect any existing kit.files.hooks override so we don't double-patch
// configs that already redirect hook entrypoints.
return /kit\s*:\s*\{[\s\S]*?files\s*:\s*\{[\s\S]*?hooks\s*:/.test(contents)
if (/kit\s*:\s*\{[\s\S]*?files\s*:\s*\{[\s\S]*?hooks\s*:/.test(contents)) {
return 'custom'
}

return 'none'
}

function patchSvelteConfigWithHooksOverride(contents: string): string | undefined {
if (svelteConfigHasHooksOverride(contents)) {
const hooksOverrideState = getSvelteConfigHooksOverrideState(contents)
if (hooksOverrideState === 'managed') {
return undefined
}
if (hooksOverrideState === 'custom') {
throw new Error('Custom SvelteKit hook entrypoints are not supported. Remove kit.files.hooks from svelte.config.js and let holo prepare manage the generated hook bridge.')
}

const singleLineKitPattern = /(kit:\s*\{)([^\n{}]*?)(\s*\},?)/m
const singleLineKitMatch = contents.match(singleLineKitPattern)
if (singleLineKitMatch) {
const opening = singleLineKitMatch[1] ?? 'kit: {'
const body = singleLineKitMatch[2] ?? ''
const closing = singleLineKitMatch[3] ?? '}'
const trimmedBody = body.trim()
const suffix = closing.trimStart().startsWith('},') ? ',' : ''
const bodyLines = trimmedBody
? trimmedBody
.split(',')
.map(segment => segment.trim())
.filter(Boolean)
.map(segment => ` ${segment},`)
: []

const replacement = [
opening,
...bodyLines,
SVELTE_HOOKS_OVERRIDE_BLOCK,
` }${suffix}`,
].join('\n')

return contents.replace(singleLineKitPattern, replacement)
}

// Try to inject the files.hooks block right after `kit: {` (with possible trailing content on the same line).
// Handle both multi-line `kit: {\n...` and single-line `kit: {}` or files without trailing newline.
// Handle multi-line `kit: {\n...` configs and files without trailing newline.
const patched = contents.replace(
/(kit:\s*\{)([^\n]*\n?)/,
(match, opening: string, rest: string) => {
const trimmedRest = rest.trimEnd()
// Single-line `kit: {}` — inject before the closing brace
if (trimmedRest === '}' || trimmedRest === '},') {
const suffix = trimmedRest.endsWith(',') ? ',' : ''
return `${opening}\n${SVELTE_HOOKS_OVERRIDE_BLOCK}\n }${suffix}\n`
}
return `${opening}${rest}${SVELTE_HOOKS_OVERRIDE_BLOCK}\n`
},
)
Expand Down Expand Up @@ -327,13 +353,13 @@ async function ensureSvelteManagedHooks(projectRoot: string): Promise<void> {
// and delete the legacy files.
if (legacyUserContents && (!hooksContents || isManagedPrepareArtifact(hooksContents))) {
await writeFileIfChanged(hooksPath, legacyUserContents)
await unlinkIfPresent(legacyHooksUserPath)
}
await unlinkIfPresent(legacyHooksUserPath)

if (legacyServerUserContents && (!hooksServerContents || isManagedPrepareArtifact(hooksServerContents))) {
await writeFileIfChanged(hooksServerPath, legacyServerUserContents)
await unlinkIfPresent(legacyHooksServerUserPath)
}
await unlinkIfPresent(legacyHooksServerUserPath)

// If the current src/hooks.ts is a legacy Holo-managed artifact or doesn't exist,
// replace it with a clean default so the user owns the file.
Expand Down Expand Up @@ -458,10 +484,9 @@ export function renderGeneratedBroadcastManifest(
const hasPresenceChannels = registry.channels.some(entry => entry.type === 'presence')
const manifestImports = hasPresenceChannels
? [
'import type { GeneratedBroadcastManifest } from \'@holo-js/core\'',
'import type { ChannelPresenceMemberFor } from \'@holo-js/broadcast\'',
]
: ['import type { GeneratedBroadcastManifest } from \'@holo-js/core\'']
: []
const eventLines = registry.broadcast.flatMap((entry, index) => {
const lines = [
' {',
Expand Down Expand Up @@ -503,6 +528,30 @@ export function renderGeneratedBroadcastManifest(
'// Generated by holo prepare. Do not edit.',
'',
...manifestImports,
...(manifestImports.length > 0 ? [''] : []),
'type GeneratedBroadcastManifestEvent = {',
' readonly name: string',
' readonly channels: readonly {',
' readonly type: \'public\' | \'private\' | \'presence\'',
' readonly pattern: string',
' }[]',
'}',
'',
'type GeneratedBroadcastManifestChannel = {',
' readonly name: string',
' readonly pattern: string',
' readonly type: \'public\' | \'private\' | \'presence\'',
' readonly params: readonly string[]',
' readonly whispers: readonly string[]',
' readonly member?: unknown',
'}',
'',
'type GeneratedBroadcastManifest = {',
' readonly version: 1',
' readonly generatedAt: string',
' readonly events: readonly GeneratedBroadcastManifestEvent[]',
' readonly channels: readonly GeneratedBroadcastManifestChannel[]',
'}',
'',
'export const broadcastManifest = {',
' version: 1,',
Expand Down
120 changes: 105 additions & 15 deletions packages/cli/src/project/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1727,6 +1727,8 @@ export async function syncManagedDriverDependencies(
const cachePackageInstalled = typeof dependencies['@holo-js/cache'] !== 'undefined'
|| typeof devDependencies['@holo-js/cache'] !== 'undefined'

requiredPackages.add('@holo-js/core')

for (const connection of Object.values(loaded.database.connections)) {
const inferredDriver = inferConnectionDriver(connection)
if (inferredDriver) {
Expand Down Expand Up @@ -1849,6 +1851,7 @@ export async function syncManagedDriverDependencies(
let changed = false
const nextVersion = `^${HOLO_PACKAGE_VERSION}`
const removableManagedPackages = new Set<string>([
'@holo-js/core',
...Object.values(DB_DRIVER_PACKAGE_NAMES),
'@holo-js/auth',
'@holo-js/auth-clerk',
Expand Down Expand Up @@ -3352,7 +3355,7 @@ function renderFrameworkRunner(options: Pick<ProjectScaffoldOptions, 'framework'
' ])',
' : new Set()',
'',
'function pipeOutput(stream, target) {',
'function pipeOutput(stream, target, onLine) {',
' if (!stream) {',
' return',
' }',
Expand All @@ -3363,36 +3366,87 @@ function renderFrameworkRunner(options: Pick<ProjectScaffoldOptions, 'framework'
' const lines = buffered.split(/\\r?\\n/)',
' buffered = lines.pop() ?? \'\'',
' for (const line of lines) {',
' onLine?.(line)',
' if (!suppressedOutput.has(line)) {',
' target.write(`${line}\\n`)',
' }',
' }',
' })',
'',
' stream.on(\'end\', () => {',
' if (buffered.length > 0) {',
' onLine?.(buffered)',
' }',
' if (buffered.length > 0 && !suppressedOutput.has(buffered)) {',
' target.write(buffered)',
' }',
' })',
'}',
'',
'function extractNextConflictPid(lines) {',
' if (framework !== \'next\' || mode !== \'dev\') {',
' return undefined',
' }',
'',
' if (!lines.some(line => line.includes(\'Another next dev server is already running.\'))) {',
' return undefined',
' }',
'',
' for (const line of lines) {',
' const match = line.match(/^- PID:\\s+(\\d+)\\s*$/)',
' if (match) {',
' return Number.parseInt(match[1], 10)',
' }',
' }',
'',
' return undefined',
'}',
'',
'async function waitForProcessExit(pid, timeoutMs = 5000) {',
' const deadline = Date.now() + timeoutMs',
' while (Date.now() < deadline) {',
' try {',
' process.kill(pid, 0)',
' } catch (error) {',
' if (error && typeof error === \'object\' && \'code\' in error && error.code === \'ESRCH\') {',
' return true',
' }',
' throw error',
' }',
'',
' await new Promise(resolve => setTimeout(resolve, 100))',
' }',
'',
' return false',
'}',
'',
'async function stopStaleNextDevServer(pid) {',
' if (!Number.isInteger(pid) || pid <= 0 || pid === process.pid) {',
' return false',
' }',
'',
' try {',
' process.kill(pid, \'SIGTERM\')',
' } catch (error) {',
' if (error && typeof error === \'object\' && \'code\' in error && error.code === \'ESRCH\') {',
' return true',
' }',
' return false',
' }',
'',
' return waitForProcessExit(pid)',
Comment on lines +3374 to +3388

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Don't treat any live Next PID as stale.

stopStaleNextDevServer() sends SIGTERM to any existing PID. The preceding Next.js error only tells us another dev server is running; it does not prove the process is stale. Starting a second holo dev will therefore kill the first healthy server instead of surfacing the conflict.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/cli/src/project/scaffold.ts` around lines 3423 - 3437,
stopStaleNextDevServer currently sends SIGTERM to any PID reported by Next and
treats a live PID as stale; change it to first verify the PID is actually a
defunct/stale Next dev server before killing it by (a) using a non-destructive
check (e.g., try process.kill(pid, '0') to confirm the process exists, then
probe the expected dev-server endpoint/port or inspect the process command line
to confirm it’s a Next dev process), (b) only call process.kill(pid, 'SIGTERM')
if those checks indicate the process is the Next dev server and not responding,
and (c) handle and return correctly on ESRCH/EPERM as now; keep
waitForProcessExit(pid) for post-terminate waiting and update
stopStaleNextDevServer to return false when the live process is confirmed
healthy instead of killing it.

'}',
'',
'if (!existsSync(binaryPath)) {',
' console.error(`[holo] Missing framework binary "${commandName}" for "${framework}". Run your package manager install first.`)',
' process.exit(1)',
'}',
'',
'const child = spawn(binaryPath, commandArgs, {',
' cwd: projectRoot,',
' env: process.env,',
' stdio: [\'inherit\', \'pipe\', \'pipe\'],',
'})',
'let child = null',
'let forwardedSignal = null',
'',
'pipeOutput(child.stdout, process.stdout)',
'pipeOutput(child.stderr, process.stderr)',
'',
'function forwardSignal(signal) {',
' if (forwardedSignal || child.exitCode !== null) {',
' if (forwardedSignal || !child || child.exitCode !== null) {',
' return',
' }',
'',
Expand All @@ -3408,15 +3462,51 @@ function renderFrameworkRunner(options: Pick<ProjectScaffoldOptions, 'framework'
' forwardSignal(\'SIGTERM\')',
'})',
'',
'child.on(\'error\', (error) => {',
'async function run() {',
' let restartedAfterConflict = false',
'',
' while (true) {',
' const stderrLines = []',
' child = spawn(binaryPath, commandArgs, {',
' cwd: projectRoot,',
' env: process.env,',
' stdio: [\'inherit\', \'pipe\', \'pipe\'],',
' })',
' forwardedSignal = null',
'',
' pipeOutput(child.stdout, process.stdout)',
' pipeOutput(child.stderr, process.stderr, line => {',
' stderrLines.push(line)',
' })',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
'',
' const code = await new Promise((resolve, reject) => {',
' child.on(\'error\', reject)',
' child.on(\'close\', resolve)',
' })',
'',
' if (code === 0) {',
' process.exit(0)',
' }',
'',
' const conflictPid = extractNextConflictPid(stderrLines)',
' if (!restartedAfterConflict && typeof conflictPid === \'number\') {',
' const stopped = await stopStaleNextDevServer(conflictPid)',
' if (stopped) {',
' restartedAfterConflict = true',
' console.error(`[holo] Stopped stale Next dev server ${conflictPid}. Restarting dev server.`)',
' continue',
' }',
' }',
'',
' process.exit(code ?? 1)',
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
' }',
'}',
'',
'run().catch((error) => {',
' console.error(error instanceof Error ? error.message : String(error))',
' process.exit(1)',
'})',
'',
'child.on(\'close\', (code) => {',
' process.exit(code ?? 1)',
'})',
'',
].join('\n')
}

Expand Down
3 changes: 2 additions & 1 deletion packages/cli/tests/broadcast-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,8 @@ export default defineChannel('chat.{roomId}', {
await expect(readFile(join(root, '.holo-js/generated/channels.ts'), 'utf8')).resolves.toContain('"whispers": [')
await expect(readFile(join(root, '.holo-js/generated/broadcast-manifest.ts'), 'utf8')).resolves.toContain('events: [')
await expect(readFile(join(root, '.holo-js/generated/broadcast-manifest.ts'), 'utf8')).resolves.toContain('"typing.start"')
await expect(readFile(join(root, '.holo-js/generated/broadcast-manifest.ts'), 'utf8')).resolves.toContain('import type { GeneratedBroadcastManifest } from \'@holo-js/core\'')
await expect(readFile(join(root, '.holo-js/generated/broadcast-manifest.ts'), 'utf8')).resolves.not.toContain('import type { GeneratedBroadcastManifest } from \'@holo-js/core\'')
await expect(readFile(join(root, '.holo-js/generated/broadcast-manifest.ts'), 'utf8')).resolves.toContain('type GeneratedBroadcastManifest = {')
await expect(readFile(join(root, '.holo-js/generated/broadcast-manifest.ts'), 'utf8')).resolves.toContain('import type { ChannelPresenceMemberFor } from \'@holo-js/broadcast\'')
await expect(readFile(join(root, '.holo-js/generated/broadcast-manifest.ts'), 'utf8')).resolves.toContain('member: undefined as unknown as ChannelPresenceMemberFor<"chat.{roomId}">')
await expect(readFile(join(root, '.holo-js/generated/broadcast.d.ts'), 'utf8')).resolves.toContain('declare module \'@holo-js/broadcast\'')
Expand Down
Loading