From 4005cfb95027bab73def41e0e326c8a58d7a3d53 Mon Sep 17 00:00:00 2001 From: Joe Averbukh Date: Thu, 30 Jul 2026 10:42:40 -0700 Subject: [PATCH 1/2] [CRA] Write-up init for self-host --- .../scripts/copyExamples.ts | 14 +- .../src/backendConfig.test.ts | 127 ++++++++++++++++++ .../create-instant-app/src/backendConfig.ts | 115 ++++++++++++++++ client/packages/create-instant-app/src/cli.ts | 32 +---- client/packages/create-instant-app/src/env.ts | 22 +-- .../packages/create-instant-app/src/index.ts | 11 +- .../create-instant-app/src/projectBase.ts | 107 +++++++++++++++ .../src/utils/getUserPkgManager.ts | 4 +- 8 files changed, 369 insertions(+), 63 deletions(-) create mode 100644 client/packages/create-instant-app/src/backendConfig.test.ts create mode 100644 client/packages/create-instant-app/src/backendConfig.ts create mode 100644 client/packages/create-instant-app/src/projectBase.ts diff --git a/client/packages/create-instant-app/scripts/copyExamples.ts b/client/packages/create-instant-app/scripts/copyExamples.ts index d7a854c87b..d886221bfb 100644 --- a/client/packages/create-instant-app/scripts/copyExamples.ts +++ b/client/packages/create-instant-app/scripts/copyExamples.ts @@ -2,17 +2,7 @@ import path from 'path'; import { fileURLToPath } from 'url'; import fs from 'fs-extra'; import { copyRespectingGitignore } from '../src/scaffold.js'; - -const EXAMPLES_TO_COPY = [ - 'expo', - 'next-js-app-dir', - 'sveltekit', - 'vite-react', - 'vite-vanilla', - 'tanstack-start', - 'vue-vite', - 'python-script', -] as const; +import { bundledProjectBases } from '../src/projectBase.js'; async function main() { const __filename = fileURLToPath(import.meta.url); @@ -23,7 +13,7 @@ async function main() { const templateBaseRoot = path.join(__dirname, '../template/base'); const copiedExamples = await Promise.all( - EXAMPLES_TO_COPY.map(async (exampleName) => { + bundledProjectBases.map(async (exampleName) => { const sourceDir = path.join(examplesRoot, exampleName); const targetDir = path.join(templateBaseRoot, exampleName); diff --git a/client/packages/create-instant-app/src/backendConfig.test.ts b/client/packages/create-instant-app/src/backendConfig.test.ts new file mode 100644 index 0000000000..494d5a56f1 --- /dev/null +++ b/client/packages/create-instant-app/src/backendConfig.test.ts @@ -0,0 +1,127 @@ +import fs from 'fs-extra'; +import os from 'os'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { afterEach, describe, expect, it } from 'vitest'; +import { applyBackendConfig, websocketURIFromAPIURI } from './backendConfig.js'; +import { projectBaseConfig, projectBases } from './projectBase.js'; + +const tempDirs: string[] = []; +const examplesDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../../../examples', +); + +const createTempDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'backend-config-')); + tempDirs.push(dir); + return dir; +}; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.removeSync(dir); + } +}); + +describe('websocketURIFromAPIURI', () => { + it.each([ + ['http://localhost:8888', 'ws://localhost:8888/runtime/session'], + ['https://instant.example', 'wss://instant.example/runtime/session'], + [ + 'https://instant.example/backend/', + 'wss://instant.example/backend/runtime/session', + ], + ])('derives a websocket URI from %s', (apiURI, expected) => { + expect(websocketURIFromAPIURI(apiURI)).toBe(expected); + }); + + it('rejects non-HTTP URIs', () => { + expect(() => websocketURIFromAPIURI('ftp://instant.example')).toThrow( + 'INSTANT_CLI_API_URI must be a valid HTTP(S) URL', + ); + }); + + it('rejects malformed URIs', () => { + expect(() => websocketURIFromAPIURI('instant.example')).toThrow( + 'INSTANT_CLI_API_URI must be a valid HTTP(S) URL', + ); + }); +}); + +describe('applyBackendConfig', () => { + it('adds API and websocket URIs to a client init', () => { + const dir = createTempDir(); + const filePath = path.join(dir, 'src/lib/db.ts'); + fs.outputFileSync( + filePath, + 'export const db = init({\n appId: "app-id",\n});\n', + ); + + applyBackendConfig('next-js-app-dir', dir, 'http://localhost:8888/'); + + expect(fs.readFileSync(filePath, 'utf8')).toBe( + 'export const db = init({\n' + + ' apiURI: "http://localhost:8888",\n' + + ' websocketURI: "ws://localhost:8888/runtime/session",\n' + + ' appId: "app-id",\n' + + '});\n', + ); + }); + + it('adds only the API URI to admin init', () => { + const dir = createTempDir(); + const clientPath = path.join(dir, 'src/lib/db.ts'); + const adminPath = path.join(dir, 'src/lib/adminDb.ts'); + fs.outputFileSync(clientPath, 'export const db = init({\n});\n'); + fs.outputFileSync(adminPath, 'export const adminDb = init({\n});\n'); + + applyBackendConfig('tanstack-start', dir, 'https://instant.example'); + + expect(fs.readFileSync(adminPath, 'utf8')).toContain( + 'init({\n apiURI: "https://instant.example",\n', + ); + expect(fs.readFileSync(adminPath, 'utf8')).not.toContain('websocketURI'); + }); + + it('does not update any files if a template cannot be configured', () => { + const dir = createTempDir(); + const clientPath = path.join(dir, 'src/lib/db.ts'); + const adminPath = path.join(dir, 'src/lib/adminDb.ts'); + const clientContents = 'export const db = init({\n});\n'; + fs.outputFileSync(clientPath, clientContents); + fs.outputFileSync(adminPath, 'export const adminDb = unknownInit({});\n'); + + expect(() => + applyBackendConfig('tanstack-start', dir, 'https://instant.example'), + ).toThrow('Could not find init({ in scaffolded database file'); + expect(fs.readFileSync(clientPath, 'utf8')).toBe(clientContents); + }); + + it('configures the Python client', () => { + const dir = createTempDir(); + const filePath = path.join(dir, 'main.py'); + fs.outputFileSync(filePath, 'db = Instant()\n'); + + applyBackendConfig('python-script', dir, 'https://instant.example/'); + + expect(fs.readFileSync(filePath, 'utf8')).toBe( + 'db = Instant(api_uri="https://instant.example")\n', + ); + }); +}); + +describe('project base backend config', () => { + it.each(projectBases)('%s matches its example template', (base) => { + for (const file of projectBaseConfig[base].backendConfigFiles) { + const contents = fs.readFileSync( + path.join(examplesDir, base, file.path), + 'utf8', + ); + const initializer = + file.type === 'python' ? 'db = Instant()' : `${file.initializer}({`; + + expect(contents).toContain(initializer); + } + }); +}); diff --git a/client/packages/create-instant-app/src/backendConfig.ts b/client/packages/create-instant-app/src/backendConfig.ts new file mode 100644 index 0000000000..0aaad505b2 --- /dev/null +++ b/client/packages/create-instant-app/src/backendConfig.ts @@ -0,0 +1,115 @@ +import fs from 'fs-extra'; +import path from 'path'; +import { + projectBaseConfig, + type BackendConfigFile, + type ProjectBase, +} from './projectBase.js'; + +const normalizeAPIURI = (apiURI: string) => apiURI.replace(/\/+$/, ''); + +export const websocketURIFromAPIURI = (apiURI: string) => { + let websocketURI: URL; + try { + websocketURI = new URL(normalizeAPIURI(apiURI)); + } catch { + throw new Error('INSTANT_CLI_API_URI must be a valid HTTP(S) URL'); + } + + if (websocketURI.protocol === 'http:') { + websocketURI.protocol = 'ws:'; + } else if (websocketURI.protocol === 'https:') { + websocketURI.protocol = 'wss:'; + } else { + throw new Error('INSTANT_CLI_API_URI must be a valid HTTP(S) URL'); + } + + websocketURI.pathname = `${websocketURI.pathname.replace(/\/+$/, '')}/runtime/session`; + websocketURI.search = ''; + websocketURI.hash = ''; + + return websocketURI.toString(); +}; + +const injectInitConfig = ({ + contents, + initializer, + apiURI, + websocketURI, +}: { + contents: string; + initializer: string; + apiURI: string; + websocketURI?: string; +}) => { + const initStart = `${initializer}({`; + if (!contents.includes(initStart)) { + throw new Error(`Could not find ${initStart} in scaffolded database file`); + } + + const config = [ + ` apiURI: ${JSON.stringify(apiURI)},`, + websocketURI ? ` websocketURI: ${JSON.stringify(websocketURI)},` : null, + ] + .filter(Boolean) + .join('\n'); + + return contents.replace(initStart, `${initStart}\n${config}`); +}; + +const injectPythonConfig = (contents: string, apiURI: string) => { + const initStart = 'db = Instant()'; + if (!contents.includes(initStart)) { + throw new Error(`Could not find ${initStart} in scaffolded database file`); + } + + return contents.replace( + initStart, + `db = Instant(api_uri=${JSON.stringify(apiURI)})`, + ); +}; + +const injectBackendConfig = ( + file: BackendConfigFile, + contents: string, + apiURI: string, + websocketURI: string, +) => { + if (file.type === 'python') { + return injectPythonConfig(contents, apiURI); + } + + return injectInitConfig({ + contents, + initializer: file.initializer, + apiURI, + websocketURI: file.websocket ? websocketURI : undefined, + }); +}; + +export const applyBackendConfig = ( + base: ProjectBase, + projectDir: string, + apiURI: string, +) => { + const normalizedAPIURI = normalizeAPIURI(apiURI); + const websocketURI = websocketURIFromAPIURI(normalizedAPIURI); + + const updates = projectBaseConfig[base].backendConfigFiles.map((file) => { + const filePath = path.join(projectDir, file.path); + const contents = fs.readFileSync(filePath, 'utf8'); + return { + filePath, + contents: injectBackendConfig( + file, + contents, + normalizedAPIURI, + websocketURI, + ), + }; + }); + + for (const update of updates) { + fs.writeFileSync(update.filePath, update.contents); + } +}; diff --git a/client/packages/create-instant-app/src/cli.ts b/client/packages/create-instant-app/src/cli.ts index 0993843b4d..934360286b 100644 --- a/client/packages/create-instant-app/src/cli.ts +++ b/client/packages/create-instant-app/src/cli.ts @@ -4,22 +4,10 @@ import { findClaudePath } from './claude.js'; import { version } from '@instantdb/version'; import { coerceAppName, validateAppName } from './utils/validateAppName.js'; import { renderUnwrap, UI } from 'instant-cli/ui'; +import { projectBases, type ProjectBase } from './projectBase.js'; export type Project = { - base: - | 'next-js-app-dir' - | 'vite-react' - | 'vite-vanilla' - | 'expo' - | 'tanstack-start' - | 'tanstack-start-with-tanstack-query' - | 'bun-react' - | 'solidjs-vite' - | 'sveltekit' - | 'vue-vite' - | 'vercel-ai-sdk' - | 'ai-chat' - | 'python-script'; + base: ProjectBase; ruleFiles: | 'cursor' | 'claude' @@ -93,21 +81,7 @@ export const runCli = async (): Promise => { new Option( '-b --base