Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
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.

5 changes: 5 additions & 0 deletions packages/adapter-next/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@
"import": "./dist/index.mjs",
"default": "./dist/index.mjs"
},
"./config": {
"types": "./dist/config.d.ts",
"import": "./dist/config.mjs",
"default": "./dist/config.mjs"
},
"./client": {
"types": "./dist/client.d.ts",
"import": "./dist/client.mjs",
Expand Down
66 changes: 66 additions & 0 deletions packages/adapter-next/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const HOLO_SERVER_EXTERNAL_PACKAGES = [
'@holo-js/core',
'@holo-js/adapter-next',
'@holo-js/db',
'@holo-js/config',
'esbuild',
]

interface NextConfig {
readonly serverExternalPackages?: string[]
readonly outputFileTracingExcludes?: Record<string, string[]>
readonly rewrites?: () => Promise<unknown>
readonly [key: string]: unknown
}

export function withHolo<TConfig extends NextConfig>(nextConfig: TConfig = {} as TConfig): TConfig {
const existingExternal = nextConfig.serverExternalPackages ?? []
const mergedExternal = [
...new Set([...HOLO_SERVER_EXTERNAL_PACKAGES, ...existingExternal]),
]

const existingExcludes = nextConfig.outputFileTracingExcludes ?? {}
const existingGlobalExcludes = existingExcludes['/*'] ?? []
const mergedExcludes = {
...existingExcludes,
'/*': [...new Set(['./next.config.ts', './next.config.mjs', ...existingGlobalExcludes])],
}

const userRewrites = nextConfig.rewrites

return {
...nextConfig,
serverExternalPackages: mergedExternal,
outputFileTracingExcludes: mergedExcludes,
async rewrites() {
const userResult = await userRewrites?.()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const raw = process.env.STORAGE_ROUTE_PREFIX?.trim() ?? '/storage'
const needsRewrite = raw && raw !== '/' && raw !== '/storage'

if (!needsRewrite) {
return userResult ?? []
}

const storageRoutePrefix = `/${raw.replace(/^\/+|\/+$/g, '')}`
const holoRewrite = {
source: `${storageRoutePrefix}/:path*`,
destination: '/storage/:path*',
}

if (Array.isArray(userResult)) {
return [...userResult, holoRewrite]
}

if (userResult && typeof userResult === 'object' && !Array.isArray(userResult)) {
const shaped = userResult as { beforeFiles?: unknown[], afterFiles?: unknown[], fallback?: unknown[] }
return {
...shaped,
beforeFiles: [...(shaped.beforeFiles ?? []), holoRewrite],
}
}

return [holoRewrite]
},
}
}
2 changes: 2 additions & 0 deletions packages/adapter-next/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,6 @@ export async function resetNextHoloProject(): Promise<void> {
await nextAdapter.resetProject()
}

export { withHolo } from './config'

export const adapterNextInternals = nextAdapter.internals
1 change: 1 addition & 0 deletions packages/adapter-next/tsup.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { defineConfig } from 'tsup'
export default defineConfig({
entry: {
index: 'src/index.ts',
config: 'src/config.ts',
client: 'src/client.ts',
},
format: ['esm'],
Expand Down
29 changes: 29 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,37 @@ 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')

let framework: 'next' | 'nuxt' | 'sveltekit'
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
}

framework = manifest.framework
} catch {
return
}

await writeTextFile(frameworkRunnerPath, renderFrameworkRunner({ framework }))
}
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: \'private\' | \'presence\'',
' readonly params: readonly string[]',
' readonly whispers: readonly string[]',
' readonly member?: Readonly<Record<string, unknown>>',
'}',
'',
'type GeneratedBroadcastManifest = {',
' readonly version: 1',
' readonly generatedAt: string',
' readonly events: readonly GeneratedBroadcastManifestEvent[]',
' readonly channels: readonly GeneratedBroadcastManifestChannel[]',
'}',
'',
'export const broadcastManifest = {',
' version: 1,',
Expand Down
Loading