Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
14 changes: 2 additions & 12 deletions client/packages/create-instant-app/scripts/copyExamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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);

Expand Down
127 changes: 127 additions & 0 deletions client/packages/create-instant-app/src/backendConfig.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
115 changes: 115 additions & 0 deletions client/packages/create-instant-app/src/backendConfig.ts
Original file line number Diff line number Diff line change
@@ -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);
}
};
32 changes: 3 additions & 29 deletions client/packages/create-instant-app/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -93,21 +81,7 @@ export const runCli = async (): Promise<Project> => {
new Option(
'-b --base <template>',
'The base template to scaffold from',
).choices([
'next-js-app-dir',
'vite-react',
'vite-vanilla',
'expo',
'bun-react',
'tanstack-start',
'tanstack-start-with-tanstack-query',
'solidjs-vite',
'sveltekit',
'vue-vite',
'vercel-ai-sdk',
'ai-chat',
'python-script',
]),
).choices(projectBases),
)
.addOption(
new Option('-g --git', 'Create a git repo in the new project').default(
Expand Down
22 changes: 3 additions & 19 deletions client/packages/create-instant-app/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,31 +1,15 @@
import fs from 'fs-extra';
import path from 'path';
import { Project } from './cli.js';

const envNames: Record<Project['base'], string> = {
'next-js-app-dir': 'NEXT_PUBLIC_INSTANT_APP_ID',
'vite-react': 'VITE_INSTANT_APP_ID',
'vite-vanilla': 'VITE_INSTANT_APP_ID',
expo: 'EXPO_PUBLIC_INSTANT_APP_ID',
'tanstack-start': 'VITE_INSTANT_APP_ID',
'bun-react': 'BUN_PUBLIC_INSTANT_APP_ID',
'solidjs-vite': 'VITE_INSTANT_APP_ID',
sveltekit: 'VITE_INSTANT_APP_ID',
'vue-vite': 'VITE_INSTANT_APP_ID',
'tanstack-start-with-tanstack-query': 'VITE_INSTANT_APP_ID',
'vercel-ai-sdk': 'NEXT_PUBLIC_INSTANT_APP_ID',
'ai-chat': 'NEXT_PUBLIC_INSTANT_APP_ID',
'python-script': 'INSTANT_APP_ID',
};
import { projectBaseConfig, type ProjectBase } from './projectBase.js';

export const applyEnvFile = (
project: Project,
base: ProjectBase,
projectDir: string,
appId: string,
adminToken: string,
) => {
const envPath = path.join(projectDir, '.env');
const envVarName = envNames[project.base];
const envVarName = projectBaseConfig[base].appIdEnvName;
const envContent = `${envVarName}=${appId}\nINSTANT_APP_ADMIN_TOKEN=${adminToken}`;

fs.writeFileSync(envPath, envContent);
Expand Down
11 changes: 10 additions & 1 deletion client/packages/create-instant-app/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { parseNameAndPath } from './utils/validateAppName.js';
import { execa } from 'execa';
import { getRules, getSchema } from './utils/appConfig.js';
import { printAppCreateResult } from './utils/printAppCreateResult.js';
import { applyBackendConfig } from './backendConfig.js';

const main = async () => {
if (
Expand All @@ -44,6 +45,14 @@ const main = async () => {

const projectDir = await scaffoldBase(project, appDir);

if (process.env.INSTANT_CLI_API_URI) {
applyBackendConfig(
project.base,
projectDir,
process.env.INSTANT_CLI_API_URI,
);
}
Comment on lines +48 to +54

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Uncaught applyBackendConfig failure crashes mid-scaffold, leaving a broken half-set-up project.

This call isn't wrapped in error handling. If INSTANT_CLI_API_URI is malformed, or a scaffolded file's expected initializer text (init({ / db = Instant()) doesn't match (e.g. future template drift), applyBackendConfig throws and the whole process dies with a raw stack trace — after projectDir already exists on disk but before rule files, env file, dependency install, package renaming, or git init have run.

🔧 Proposed fix: surface a clear, non-crashing error
   if (process.env.INSTANT_CLI_API_URI) {
-    applyBackendConfig(
-      project.base,
-      projectDir,
-      process.env.INSTANT_CLI_API_URI,
-    );
+    try {
+      applyBackendConfig(
+        project.base,
+        projectDir,
+        process.env.INSTANT_CLI_API_URI,
+      );
+    } catch (err) {
+      console.error(
+        `Warning: could not apply self-hosted backend config: ${
+          err instanceof Error ? err.message : err
+        }`,
+      );
+      console.error(
+        'You may need to manually set apiURI/websocketURI in your backend init.',
+      );
+    }
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (process.env.INSTANT_CLI_API_URI) {
applyBackendConfig(
project.base,
projectDir,
process.env.INSTANT_CLI_API_URI,
);
}
if (process.env.INSTANT_CLI_API_URI) {
try {
applyBackendConfig(
project.base,
projectDir,
process.env.INSTANT_CLI_API_URI,
);
} catch (err) {
console.error(
`Warning: could not apply self-hosted backend config: ${
err instanceof Error ? err.message : err
}`,
);
console.error(
'You may need to manually set apiURI/websocketURI in your backend init.',
);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@client/packages/create-instant-app/src/index.ts` around lines 48 - 54, Wrap
the applyBackendConfig call guarded by INSTANT_CLI_API_URI in error handling,
catching malformed URI or template-initializer failures. Surface a concise,
user-facing error through the existing CLI error mechanism, avoid the raw stack
trace, and stop the scaffold flow before continuing with subsequent setup steps.


addRuleFiles({
projectDir,
ruleFilesToAdd: project.ruleFiles,
Expand All @@ -68,7 +77,7 @@ const main = async () => {
printAppCreateResult(possibleAppTokenPair);
if (possibleAppTokenPair) {
applyEnvFile(
project,
project.base,
projectDir,
possibleAppTokenPair.appId,
possibleAppTokenPair.adminToken,
Expand Down
Loading
Loading