Skip to content
Open
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
3 changes: 2 additions & 1 deletion apps/quick-dapp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"dependencies": {
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@drafish/surge-client": "^1.1.5"
"@drafish/surge-client": "^1.1.5",
"esbuild-wasm": "^0.25.12"
}
}
7 changes: 6 additions & 1 deletion apps/quick-dapp/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import React, { useEffect, useReducer, useState } from 'react';
import { IntlProvider } from 'react-intl'
import CreateInstance from './components/CreateInstance';
import EditInstance from './components/EditInstance';
import EditHtmlTemplate from './components/EditHtmlTemplate';
import DeployPanel from './components/DeployPanel';
import LoadingScreen from './components/LoadingScreen';
import { appInitialState, appReducer } from './reducers/state';
Expand Down Expand Up @@ -52,7 +53,11 @@ function App(): JSX.Element {
}}
>
<IntlProvider locale={locale.code} messages={locale.messages}>
{Object.keys(appState.instance.abi).length > 0 ? (
{appState.instance.htmlTemplate ? (
<div className="container-fluid pt-3">
<EditHtmlTemplate />
</div>
) : Object.keys(appState.instance.abi).length > 0 ? (
<div className="row m-0 pt-3">
<EditInstance />
<DeployPanel />
Expand Down
281 changes: 281 additions & 0 deletions apps/quick-dapp/src/InBrowserVite.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
// InBrowserVite - Class-based esbuild builder for in-browser bundling
// Extracted from BrowserVite.tsx to provide a reusable, non-React API

export interface BuildResult {
js: string;
success: boolean;
error?: string;
}

export class InBrowserVite {
private esbuild: any = null;
private initialized = false;
private initPromise: Promise<void> | null = null;

/**
* Initialize esbuild-wasm. This is async and should be called before build.
* Subsequent calls return the same initialization promise.
*/
async initialize(): Promise<void> {
if (this.initialized) return;
if (this.initPromise) return this.initPromise;

this.initPromise = (async () => {
try {
// dynamic import for ESM browser build
// @ts-ignore
if (!window.esbuild) {
throw new Error('esbuild not found on window. Make sure to include esbuild-wasm script.'); }

this.esbuild = (window as any).esbuild;
this.initialized = true;
} catch (err) {
this.initPromise = null;
throw new Error(`esbuild initialization failed: ${err.message}`);
}
})();

return this.initPromise;
}

/**
* Check if esbuild is initialized and ready
*/
isReady(): boolean {
return this.initialized && this.esbuild !== null;
}

/**
* Build the entry point with the given virtual filesystem
* @param files Map of file paths to their contents
* @param entry Entry point path (default: auto-detect)
* @returns BuildResult with js output or error
*/
async build(files: Map<string, string>, entry?: string): Promise<BuildResult> {
if (!this.isReady()) {
return {
js: '',
success: false,
error: 'esbuild not initialized. Call initialize() first.',
};
}

try {
// Log available files for debugging
console.log('[InBrowserVite] Available files:', Array.from(files.keys()));

// Auto-detect entry point if not provided or if it's an HTML file
let actualEntry = entry;
if (!actualEntry || !this.isBuildableEntry(actualEntry)) {
actualEntry = this.findEntryPoint(files);
if (!actualEntry) {
return {
js: '',
success: false,
error: 'No valid JavaScript/TypeScript entry point found. Please provide a .js, .jsx, .ts, or .tsx file.',
};
}
}

console.log('[InBrowserVite] Using entry point:', actualEntry);

const plugin = this.makePlugin(files);
const result = await this.esbuild.build({
entryPoints: [actualEntry],
bundle: true,
write: false,
format: 'esm',
plugins: [plugin],
define: { 'process.env.NODE_ENV': '"production"' },
loader: {
'.js': 'jsx', // Allow JSX in .js files
},
});

const js = result.outputFiles[0].text;
return {
js,
success: true,
};
} catch (err) {
return {
js: '',
success: false,
error: err.message || err.toString(),
};
}
}

/**
* Find a valid entry point from the files map
*/
private findEntryPoint(files: Map<string, string>): string | null {
// Common entry point patterns in order of preference
const patterns = [
'/src/main.jsx',
'/src/main.js',
'/src/index.jsx',
'/src/index.js',
'/main.jsx',
'/main.js',
'/index.jsx',
'/index.js',
'/src/App.jsx',
'/src/App.js',
'/App.jsx',
'/App.js',
];

// Check common patterns first
for (const pattern of patterns) {
if (files.has(pattern)) {
return pattern;
}
}

// Find any buildable file
for (const [path] of files) {
if (this.isBuildableEntry(path)) {
return path;
}
}

return null;
}

/**
* Create esbuild plugin that resolves bare imports to esm.sh and loads files from in-memory map
*/
private makePlugin(map: Map<string, string>) {
return {
name: 'virtual-fs-and-cdn',
setup: (build: any) => {
// resolve absolute paths (starting with /)
build.onResolve({ filter: /^\/.*/ }, (args: any) => {
return { path: args.path, namespace: 'local' };
});

// resolve relative paths (starting with ./ or ../)
build.onResolve({ filter: /^\.\.?\/.*/ }, (args: any) => {
// Resolve relative to the importer
const importerDir = args.importer ? args.importer.substring(0, args.importer.lastIndexOf('/')) : '';
let resolvedPath = this.resolvePath(importerDir, args.path);
return { path: resolvedPath, namespace: 'local' };
});

// resolve bare specifiers (like react, app.jsx)
build.onResolve({ filter: /^[^./].*/ }, (args: any) => {
// if it's an absolute URL, set namespace to external
if (args.path.startsWith('http')) {
return { path: args.path, namespace: 'external' };
}

// Check if this bare specifier exists as a local file
// Try common locations (with and without leading slash)
const possiblePaths = [
args.path, // bare: app.jsx
`/${args.path}`, // absolute: /app.jsx
`/src/${args.path}`, // src directory: /src/app.jsx
`src/${args.path}`, // src directory (no leading slash)
args.importer ? `${args.importer.substring(0, args.importer.lastIndexOf('/'))}/${args.path}` : null,
].filter(Boolean);

for (const testPath of possiblePaths) {
if (map.has(testPath)) {
// Normalize to absolute path with leading slash
const normalizedPath = testPath.startsWith('/') ? testPath : `/${testPath}`;
console.log(`[InBrowserVite] Resolved '${args.path}' to local file '${normalizedPath}'`);
return { path: normalizedPath, namespace: 'local' };
}
}

// Not a local file, treat as external CDN import
console.log(`[InBrowserVite] Resolved '${args.path}' to CDN: https://esm.sh/${args.path}`);
return { path: `https://esm.sh/${args.path}`, namespace: 'external' };
});

// load local files
build.onLoad({ filter: /.*/, namespace: 'local' }, async (args: any) => {
// Try both with and without leading slash
const pathsToTry = [
args.path,
args.path.startsWith('/') ? args.path.substring(1) : `/${args.path}`,
];

for (const testPath of pathsToTry) {
if (map.has(testPath)) {
const contents = map.get(testPath);
const loader = this.guessLoader(args.path);
return { contents, loader };
}
}

return { contents: `throw new Error('File not found in virtual filesystem: ${args.path}')`, loader: 'js' };
});

// load http(s) files (simple fetch)
build.onLoad({ filter: /^https?:\/\//, namespace: 'external' }, async (args: any) => {
try {
const res = await fetch(args.path);
const contents = await res.text();
// try to infer loader from extension
const loader = this.guessLoader(args.path);
return { contents, loader };
} catch (err) {
return { contents: `throw new Error('Failed to fetch ${args.path}: ${err.message}')` , loader: 'js' };
}
});
}
};
}

/**
* Resolve a relative path against a base directory
*/
private resolvePath(base: string, relative: string): string {
// Normalize base to always be a directory path
if (!base) base = '/';
if (!base.startsWith('/')) base = '/' + base;
if (!base.endsWith('/')) base = base + '/';

// Handle different relative patterns
const parts = base.split('/').filter(Boolean);
const relativeParts = relative.split('/');

for (const part of relativeParts) {
if (part === '..') {
parts.pop();
} else if (part !== '.') {
parts.push(part);
}
}

return '/' + parts.join('/');
}

/**
* Guess the appropriate esbuild loader based on file extension
*/
private guessLoader(path: string): string {
if (path.endsWith('.ts')) return 'ts';
if (path.endsWith('.tsx')) return 'tsx';
if (path.endsWith('.jsx')) return 'jsx';
if (path.endsWith('.css')) return 'css';
if (path.endsWith('.json')) return 'json';
if (path.endsWith('.html')) return 'text'; // HTML files as text, not code
// Default to 'jsx' for .js, .mjs and other files to support JSX syntax
return 'jsx';
}

/**
* Check if a file path is a buildable entry point
*/
private isBuildableEntry(path: string): boolean {
const ext = path.toLowerCase();
return ext.endsWith('.js') ||
ext.endsWith('.jsx') ||
ext.endsWith('.ts') ||
ext.endsWith('.tsx') ||
ext.endsWith('.mjs');
}
}
Loading