diff --git a/.changeset/fuzzy-workers-build.md b/.changeset/fuzzy-workers-build.md new file mode 100644 index 00000000..5d2ca25d --- /dev/null +++ b/.changeset/fuzzy-workers-build.md @@ -0,0 +1,5 @@ +--- +"@tanstack/create": patch +--- + +Fix Worker usage by adding a provider-based `@tanstack/create/worker` entry that avoids importing the full generated create manifest at startup. diff --git a/docs/cli-reference.md b/docs/cli-reference.md index f9b9bb21..a7ad0ee8 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -44,31 +44,76 @@ tanstack create --list-add-ons --framework React --json tanstack create --addon-details drizzle --framework React --json ``` -### Worker-safe programmatic generation +### Programmatic generation -Use `@tanstack/create/edge` in Cloudflare Workers and other runtimes that do not expose a Node package filesystem. The default `@tanstack/create` export is still the Node/CLI path and scans framework templates from disk. +Use `@tanstack/create/worker` in Cloudflare Workers and other edge SSR runtimes. It does not import the generated template manifest at module startup. Instead, provide a loader for the framework and add-on chunks your Worker supports. + +The default `@tanstack/create` export is still the Node/CLI path and scans framework templates from disk. `@tanstack/create/edge` remains the bundled in-memory manifest path; it is Worker-compatible at runtime, but it imports the full generated manifest and is not appropriate for size-constrained Worker bundles. ```ts import { - createApp, createMemoryEnvironment, - finalizeAddOns, - getFrameworkById, - populateAddOnOptionsDefaults, -} from '@tanstack/create/edge' + createWorkerCreate, + createWorkerManifestLoader, +} from '@tanstack/create/worker' +import { manifestCatalog } from '@tanstack/create/worker-manifest/catalog' + +import type { + WorkerAddOnManifestModule, + WorkerFrameworkManifestModule, +} from '@tanstack/create/worker' + +const frameworkLoaders: Record< + string, + () => Promise +> = { + react: () => import('@tanstack/create/worker-manifest/frameworks/react'), +} + +const addOnLoaders: Record< + string, + Record Promise> +> = { + react: { + 'tanstack-query': () => + import( + '@tanstack/create/worker-manifest/frameworks/react/add-ons/tanstack-query' + ), + cloudflare: () => + import( + '@tanstack/create/worker-manifest/frameworks/react/add-ons/cloudflare' + ), + }, +} -const framework = getFrameworkById('react')! -const chosenAddOns = await finalizeAddOns(framework, 'file-router', [ +const create = createWorkerCreate( + createWorkerManifestLoader({ + loadCatalog: async () => manifestCatalog, + async loadFramework(frameworkId) { + const load = frameworkLoaders[frameworkId] + if (!load) throw new Error(`Unsupported framework: ${frameworkId}`) + return load() + }, + async loadAddOn(frameworkId, addOnId) { + const load = addOnLoaders[frameworkId]?.[addOnId] + if (!load) throw new Error(`Unsupported add-on: ${addOnId}`) + return load() + }, + }), +) + +const framework = await create.getFrameworkById('react') +const chosenAddOns = await create.finalizeAddOns(framework!, 'file-router', [ 'tanstack-query', 'cloudflare', ]) -const addOnOptions = populateAddOnOptionsDefaults(chosenAddOns) +const addOnOptions = create.populateAddOnOptionsDefaults(chosenAddOns) const { environment, output } = createMemoryEnvironment('/app') -await createApp(environment, { +await create.createApp(environment, { projectName: 'app', targetDir: '/app', - framework, + framework: framework!, mode: 'file-router', typescript: true, tailwind: true, diff --git a/packages/create/package.json b/packages/create/package.json index d6dd97d4..9f71a3ce 100644 --- a/packages/create/package.json +++ b/packages/create/package.json @@ -16,6 +16,21 @@ "import": "./dist/edge.js", "default": "./dist/edge.js" }, + "./worker": { + "types": "./dist/types/worker.d.ts", + "import": "./dist/worker.js", + "default": "./dist/worker.js" + }, + "./worker/bundled": { + "types": "./dist/types/worker-bundled.d.ts", + "import": "./dist/worker-bundled.js", + "default": "./dist/worker-bundled.js" + }, + "./worker-manifest/*": { + "types": "./dist/types/generated/worker/*.d.ts", + "import": "./dist/generated/worker/*.js", + "default": "./dist/generated/worker/*.js" + }, "./manifest": { "types": "./dist/types/manifest.d.ts", "import": "./dist/manifest.js", @@ -31,7 +46,7 @@ "dev": "npm run generate-manifest && tsc --watch", "test": "npm run generate-manifest && eslint ./src && vitest run", "test:watch": "npm run generate-manifest && vitest", - "test:coverage": "vitest run --coverage" + "test:coverage": "npm run generate-manifest && vitest run --coverage" }, "repository": { "type": "git", diff --git a/packages/create/scripts/generate-manifest.mjs b/packages/create/scripts/generate-manifest.mjs index 3a6041e4..de87ed0d 100644 --- a/packages/create/scripts/generate-manifest.mjs +++ b/packages/create/scripts/generate-manifest.mjs @@ -3,6 +3,7 @@ import { mkdirSync, readFileSync, readdirSync, + rmSync, writeFileSync, } from 'node:fs' import { dirname, extname, join, relative, resolve } from 'node:path' @@ -11,6 +12,7 @@ import { fileURLToPath } from 'node:url' const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') const frameworksDir = resolve(packageDir, 'src/frameworks') const outputFile = resolve(packageDir, 'src/generated/create-manifest.ts') +const workerOutputDir = resolve(packageDir, 'src/generated/worker') const binaryExtensions = new Set(['.png', '.jpg', '.jpeg', '.gif', '.svg', '.ico']) const templateRenderers = new Map() @@ -250,8 +252,8 @@ function compileTemplate(template) { return lines.join('\n') } -function createTemplateRendererSource() { - const entries = Array.from(templateRenderers.entries()).sort(([a], [b]) => +function createTemplateRendererSource(renderers = templateRenderers) { + const entries = Array.from(renderers.entries()).sort(([a], [b]) => a.localeCompare(b), ) @@ -331,7 +333,7 @@ type TemplateRenderContext = { getPackageManagerExecuteScript: (...args: Array) => string relativePath: (...args: Array) => string integrationImportContent: (...args: Array) => string - integrationImportCode: (...args: Array) => string + integrationImportCode: (...args: Array) => string | undefined renderTemplate: (content: string) => string ignoreFile: () => never } @@ -376,6 +378,10 @@ const templateRenderers: Record = { ${mapEntries} } +export function hasManifestTemplate(template: string) { + return getManifestTemplateKey(template) in templateRenderers +} + export function renderManifestTemplate( template: string, context: TemplateRenderContext, @@ -390,6 +396,162 @@ export function renderManifestTemplate( ` } +function createTemplateRenderersForFrameworkBase(framework) { + const renderers = new Map() + + for (const [file, contents] of Object.entries(framework.base)) { + if (file.endsWith('.ejs')) { + renderers.set(getTemplateKey(contents), compileTemplate(contents)) + } + } + + return renderers +} + +function createTemplateRenderersForAddOn(addOn) { + const renderers = new Map() + + for (const [file, contents] of Object.entries(addOn.files)) { + if (file.endsWith('.ejs')) { + renderers.set(getTemplateKey(contents), compileTemplate(contents)) + } + } + + if (addOn.packageTemplate) { + renderers.set( + getTemplateKey(addOn.packageTemplate), + compileTemplate(addOn.packageTemplate), + ) + } + + if (addOn.readmeIsEjs && addOn.readme) { + renderers.set(getTemplateKey(addOn.readme), compileTemplate(addOn.readme)) + } + + return renderers +} + +function stripAddOnForCatalog(addOn) { + const { + files: _files, + deletedFiles: _deletedFiles, + packageTemplate: _packageTemplate, + readme: _readme, + readmeIsEjs: _readmeIsEjs, + ...metadata + } = addOn + + return metadata +} + +function stripFrameworkForCatalog(framework) { + const { base: _base, addOns, ...metadata } = framework + + return { + ...metadata, + addOns: addOns.map(stripAddOnForCatalog), + } +} + +function toModuleSegment(value) { + return value.toLowerCase().replace(/[^a-z0-9_-]+/g, '-') +} + +function writeGeneratedModule(file, source) { + mkdirSync(dirname(file), { recursive: true }) + writeFileSync( + file, + `// Generated by scripts/generate-manifest.mjs. Do not edit by hand.\n${source}`, + ) +} + +function writeWorkerManifest(manifest) { + rmSync(workerOutputDir, { recursive: true, force: true }) + + writeGeneratedModule( + join(workerOutputDir, 'catalog.ts'), + `export const manifestCatalog = ${JSON.stringify( + { + frameworks: manifest.map(stripFrameworkForCatalog), + }, + null, + 2, + )}\n`, + ) + + const frameworkLoaders = [] + const addOnLoaders = [] + + for (const framework of manifest) { + const frameworkSegment = toModuleSegment(framework.id) + const frameworkModule = `./frameworks/${frameworkSegment}.js` + frameworkLoaders.push([framework.id, frameworkModule]) + + writeGeneratedModule( + join(workerOutputDir, 'frameworks', `${frameworkSegment}.ts`), + `${createTemplateRendererSource( + createTemplateRenderersForFrameworkBase(framework), + )}\n\nexport const framework = ${JSON.stringify( + { + id: framework.id, + base: framework.base, + }, + null, + 2, + )}\n`, + ) + + for (const addOn of framework.addOns) { + const addOnSegment = toModuleSegment(addOn.id) + const addOnModule = `./frameworks/${frameworkSegment}/add-ons/${addOnSegment}.js` + addOnLoaders.push([framework.id, addOn.id, addOnModule]) + + writeGeneratedModule( + join( + workerOutputDir, + 'frameworks', + frameworkSegment, + 'add-ons', + `${addOnSegment}.ts`, + ), + `${createTemplateRendererSource( + createTemplateRenderersForAddOn(addOn), + )}\n\nexport const addOn = ${JSON.stringify(addOn, null, 2)}\n`, + ) + } + } + + const frameworkLoaderSource = frameworkLoaders + .map( + ([frameworkId, modulePath]) => + ` ${JSON.stringify(frameworkId)}: () => import(${JSON.stringify( + modulePath, + )}),`, + ) + .join('\n') + + const addOnLoaderGroups = manifest + .map((framework) => { + const entries = addOnLoaders + .filter(([frameworkId]) => frameworkId === framework.id) + .map( + ([, addOnId, modulePath]) => + ` ${JSON.stringify(addOnId)}: () => import(${JSON.stringify( + modulePath, + )}),`, + ) + .join('\n') + + return ` ${JSON.stringify(framework.id)}: {\n${entries}\n },` + }) + .join('\n') + + writeGeneratedModule( + join(workerOutputDir, 'bundled-loader.ts'), + `const frameworkLoaders = {\n${frameworkLoaderSource}\n}\n\nconst addOnLoaders = {\n${addOnLoaderGroups}\n}\n\nexport function createBundledWorkerManifestLoader() {\n return {\n async loadCatalog() {\n const module = await import('./catalog.js')\n return module.manifestCatalog\n },\n async loadFramework(frameworkId: string) {\n const load = frameworkLoaders[frameworkId as keyof typeof frameworkLoaders]\n if (!load) {\n throw new Error(\`Framework \${frameworkId} not found in bundled worker manifest\`)\n }\n const module = await load()\n return {\n ...module.framework,\n renderManifestTemplate: module.renderManifestTemplate,\n hasManifestTemplate: module.hasManifestTemplate,\n }\n },\n async loadAddOn(frameworkId: string, addOnId: string) {\n const frameworkAddOnLoaders = addOnLoaders[frameworkId as keyof typeof addOnLoaders]\n const load = frameworkAddOnLoaders?.[addOnId as keyof typeof frameworkAddOnLoaders]\n if (!load) {\n throw new Error(\`Add-on \${addOnId} not found in bundled worker manifest for framework \${frameworkId}\`)\n }\n const module = await load()\n return {\n ...module.addOn,\n renderManifestTemplate: module.renderManifestTemplate,\n hasManifestTemplate: module.hasManifestTemplate,\n }\n },\n }\n}\n`, + ) +} + const manifest = [createFramework('react'), createFramework('solid')] mkdirSync(dirname(outputFile), { recursive: true }) @@ -405,3 +567,5 @@ writeFileSync( 2, )}\n`, ) + +writeWorkerManifest(manifest) diff --git a/packages/create/src/edge-frameworks.ts b/packages/create/src/edge-frameworks.ts index 84d65e74..bcd0043e 100644 --- a/packages/create/src/edge-frameworks.ts +++ b/packages/create/src/edge-frameworks.ts @@ -1,4 +1,11 @@ -import { createManifestFrameworks } from './generated/create-manifest.js' +import { + createManifestFrameworks, + renderManifestTemplate, +} from './generated/create-manifest.js' +import { + registerTemplateRenderer, + setDefaultTemplateRenderer, +} from './edge-render.js' import type { AddOn, @@ -33,7 +40,13 @@ export function createFrameworkFromManifest( } } -const frameworks = createManifestFrameworks().map(createFrameworkFromManifest) +setDefaultTemplateRenderer(renderManifestTemplate) + +const frameworks = createManifestFrameworks().map((frameworkDefinition) => { + const framework = createFrameworkFromManifest(frameworkDefinition) + registerTemplateRenderer(framework, renderManifestTemplate) + return framework +}) export function getFrameworkById(id: string) { if (id === 'react-cra') { diff --git a/packages/create/src/edge-package-json.ts b/packages/create/src/edge-package-json.ts index abeef840..bdc9704a 100644 --- a/packages/create/src/edge-package-json.ts +++ b/packages/create/src/edge-package-json.ts @@ -1,4 +1,4 @@ -import { render } from './edge-render.js' +import { renderForOptions } from './edge-render.js' import { formatCommand, sortObject } from './utils.js' import { getPackageManagerExecuteCommand } from './package-manager.js' @@ -151,7 +151,9 @@ export function createPackageJSON(options: Options) { } try { - addOnPackageJSON = JSON.parse(render(addOn.packageTemplate, templateValues)) + addOnPackageJSON = JSON.parse( + renderForOptions(options, addOn.packageTemplate, templateValues), + ) } catch (error) { console.error( `Error processing package.json.ejs for add-on ${addOn.id}:`, diff --git a/packages/create/src/edge-render.ts b/packages/create/src/edge-render.ts index 99ed3a70..db47fa10 100644 --- a/packages/create/src/edge-render.ts +++ b/packages/create/src/edge-render.ts @@ -1,7 +1,93 @@ -import { renderManifestTemplate } from './generated/create-manifest.js' +import type { PackageManager } from './package-manager.js' +import type { AddOn, Integration, Options } from './types.js' -export function render(template: string, data?: Record) { - return renderManifestTemplate(template, { +type TemplateRoute = NonNullable[number] + +export type TemplateRenderContext = { + [key: string]: unknown + packageManager: PackageManager | undefined + projectName: string | undefined + typescript: boolean | undefined + tailwind: boolean | undefined + js: string | undefined + jsx: string | undefined + fileRouter: boolean | undefined + codeRouter: boolean | undefined + routerOnly: boolean | undefined + includeExamples: boolean | undefined + addOnEnabled: Record + addOnOption: Record> + addOns: Array + integrations: Array + routes: Array + getPackageManagerAddScript: (packageName: string, isDev?: boolean) => string + getPackageManagerRunScript: (script: string) => string + getPackageManagerExecuteScript: (pkg: string, args?: Array) => string + relativePath: (path: string, stripExtension?: boolean) => string + integrationImportContent: (integration: Integration) => string + integrationImportCode: (integration: Integration) => string | undefined + renderTemplate: (content: string) => string + ignoreFile: () => never +} + +export type TemplateRenderer = ( + template: string, + context: TemplateRenderContext, +) => string | undefined + +const templateRenderers = new WeakMap() +let defaultTemplateRenderer: TemplateRenderer | undefined + +export function setDefaultTemplateRenderer(renderer: TemplateRenderer) { + defaultTemplateRenderer = renderer +} + +export function registerTemplateRenderer( + owner: object, + renderer: TemplateRenderer, +) { + templateRenderers.set(owner, renderer) +} + +function getTemplateRenderer(owner?: object): TemplateRenderer { + const renderer = + (owner ? templateRenderers.get(owner) : undefined) ?? + defaultTemplateRenderer + + if (!renderer) { + throw new Error( + 'No template renderer has been registered for this manifest. Use @tanstack/create/worker with a manifest loader, or @tanstack/create/edge for the bundled manifest.', + ) + } + + return renderer +} + +export function render( + template: string, + data?: Partial, +) { + return renderWithRenderer(getTemplateRenderer(), template, data) +} + +export function renderForOptions( + options: Options, + template: string, + data?: Partial, +) { + return renderWithRenderer( + getTemplateRenderer(options.framework), + template, + data, + ) +} + +function renderWithRenderer( + renderer: TemplateRenderer, + template: string, + data?: Partial, +) { + return renderer(template, { packageManager: undefined, projectName: undefined, typescript: undefined, @@ -28,5 +114,5 @@ export function render(template: string, data?: Record) { throw new Error('ignoreFile') }, ...(data ?? {}), - }) + }) ?? '' } diff --git a/packages/create/src/edge-template-file.ts b/packages/create/src/edge-template-file.ts index ceee227c..bf7d67de 100644 --- a/packages/create/src/edge-template-file.ts +++ b/packages/create/src/edge-template-file.ts @@ -1,4 +1,4 @@ -import { render } from './edge-render.js' +import { renderForOptions } from './edge-render.js' import { relativePath } from './edge-file-helpers.js' import { joinPaths } from './edge-path.js' import { formatCommand } from './utils.js' @@ -152,7 +152,7 @@ export function createTemplateFile(environment: Environment, options: Options) { integrationImportCode, renderTemplate: (templateContent: string) => { - return render(templateContent, templateValues) + return renderForOptions(options, templateContent, templateValues) }, ignoreFile: () => { @@ -164,7 +164,7 @@ export function createTemplateFile(environment: Environment, options: Options) { if (file.endsWith('.ejs')) { try { - content = render(content, templateValues) + content = renderForOptions(options, content, templateValues) } catch (error) { if (error instanceof IgnoreFileError) { ignoreFile = true diff --git a/packages/create/src/manifest-types.ts b/packages/create/src/manifest-types.ts index 64883bd1..27685c4b 100644 --- a/packages/create/src/manifest-types.ts +++ b/packages/create/src/manifest-types.ts @@ -6,3 +6,69 @@ export type ManifestFrameworkDefinition = Omit< > & { addOns: Array } + +export type ManifestAddOnMetadata = Omit< + AddOnCompiled, + 'files' | 'deletedFiles' | 'packageTemplate' | 'readme' | 'readmeIsEjs' +> + +export type ManifestFrameworkMetadata = Omit< + ManifestFrameworkDefinition, + 'base' | 'addOns' +> & { + addOns: Array +} + +export type ManifestCatalog = { + frameworks: Array +} + +export type ManifestTemplateContext = Record + +export type ManifestTemplateRenderer = ( + template: string, + context: ManifestTemplateContext, +) => string | undefined + +export type ManifestTemplateModule = { + renderManifestTemplate: ManifestTemplateRenderer + hasManifestTemplate?: (template: string) => boolean +} + +export type ManifestFrameworkChunk = ManifestTemplateModule & { + id: string + base: Record +} + +export type ManifestAddOnChunk = ManifestTemplateModule & AddOnCompiled + +export type WorkerManifestLoader = { + loadCatalog: () => Promise + loadFramework: (frameworkId: string) => Promise + loadAddOn: ( + frameworkId: string, + addOnId: string, + ) => Promise +} + +export type WorkerFrameworkManifestModule = ManifestTemplateModule & { + framework: { + id: string + base: Record + } +} + +export type WorkerAddOnManifestModule = ManifestTemplateModule & { + addOn: AddOnCompiled +} + +export type WorkerManifestModuleLoader = { + loadCatalog: () => Promise + loadFramework: ( + frameworkId: string, + ) => Promise + loadAddOn: ( + frameworkId: string, + addOnId: string, + ) => Promise +} diff --git a/packages/create/src/worker-bundled.ts b/packages/create/src/worker-bundled.ts new file mode 100644 index 00000000..238751cc --- /dev/null +++ b/packages/create/src/worker-bundled.ts @@ -0,0 +1,15 @@ +export { createWorkerCreate, createWorkerManifestLoader } from './worker.js' +export { createBundledWorkerManifestLoader } from './generated/worker/bundled-loader.js' + +export type { + ManifestAddOnChunk, + ManifestAddOnMetadata, + ManifestCatalog, + ManifestFrameworkChunk, + ManifestFrameworkMetadata, + ManifestTemplateModule, + WorkerAddOnManifestModule, + WorkerFrameworkManifestModule, + WorkerManifestModuleLoader, + WorkerManifestLoader, +} from './manifest-types.js' diff --git a/packages/create/src/worker.ts b/packages/create/src/worker.ts new file mode 100644 index 00000000..ebfd37f2 --- /dev/null +++ b/packages/create/src/worker.ts @@ -0,0 +1,352 @@ +import { createApp as createEdgeApp } from './edge-create-app.js' +import { + finalizeAddOns as finalizeBaseAddOns, + getAllAddOns, + loadRemoteAddOn, + populateAddOnOptionsDefaults, +} from './edge-add-ons.js' +import { registerTemplateRenderer } from './edge-render.js' +import { createMemoryEnvironment } from './edge-environment.js' +import { CONFIG_FILE } from './constants.js' +import { + DEFAULT_PACKAGE_MANAGER, + SUPPORTED_PACKAGE_MANAGERS, + getPackageManagerExecuteCommand, + getPackageManagerInstallCommand, + getPackageManagerScriptCommand, + translateExecuteCommand, +} from './package-manager.js' + +import type { + ManifestAddOnChunk, + ManifestAddOnMetadata, + ManifestFrameworkChunk, + ManifestFrameworkMetadata, + ManifestTemplateModule, + WorkerManifestLoader, + WorkerManifestModuleLoader, +} from './manifest-types.js' +import type { TemplateRenderContext } from './edge-render.js' +import type { + AddOn, + Environment, + Framework, + Options, +} from './types.js' +import type { MemoryEnvironmentOutput } from './edge-environment.js' + +export function createWorkerManifestLoader( + moduleLoader: WorkerManifestModuleLoader, +): WorkerManifestLoader { + return { + async loadCatalog() { + return moduleLoader.loadCatalog() + }, + async loadFramework(frameworkId: string) { + const module = await moduleLoader.loadFramework(frameworkId) + return { + ...module.framework, + renderManifestTemplate: module.renderManifestTemplate, + hasManifestTemplate: module.hasManifestTemplate, + } + }, + async loadAddOn(frameworkId: string, addOnId: string) { + const module = await moduleLoader.loadAddOn(frameworkId, addOnId) + return { + ...module.addOn, + renderManifestTemplate: module.renderManifestTemplate, + hasManifestTemplate: module.hasManifestTemplate, + } + }, + } +} + +function createChunkRenderer(chunks: () => Array) { + return (template: string, context: TemplateRenderContext) => { + for (const chunk of chunks()) { + if (chunk.hasManifestTemplate && !chunk.hasManifestTemplate(template)) { + continue + } + + try { + return chunk.renderManifestTemplate(template, context) + } catch (error) { + if (chunk.hasManifestTemplate) { + throw error + } + } + } + + throw new Error('Template was not loaded by the worker manifest provider') + } +} + +export type WorkerCreateAPI = { + getFrameworks: () => Promise> + getFrameworkById: (id: string) => Promise + getFrameworkByName: (name: string) => Promise + getAllAddOns: typeof getAllAddOns + finalizeAddOns: ( + framework: Framework, + mode: string, + chosenAddOnIDs: Array, + ) => Promise> + populateAddOnOptionsDefaults: typeof populateAddOnOptionsDefaults + createApp: (environment: Environment, options: Options) => Promise +} + +export function createWorkerCreate( + manifestLoader: WorkerManifestLoader, +): WorkerCreateAPI { + let frameworksPromise: Promise> | undefined + const frameworkChunks = new Map>() + const addOnChunks = new Map>() + const loadedRendererChunks = new Map>() + const addOnFrameworkIds = new WeakMap() + + function getRendererChunks(frameworkId: string) { + const chunks = loadedRendererChunks.get(frameworkId) + if (chunks) { + return chunks + } + + const nextChunks: Array = [] + loadedRendererChunks.set(frameworkId, nextChunks) + return nextChunks + } + + function registerRendererChunk( + frameworkId: string, + chunk: ManifestTemplateModule, + ) { + const chunks = getRendererChunks(frameworkId) + if (!chunks.includes(chunk)) { + chunks.push(chunk) + } + } + + function getRenderer(frameworkId: string) { + return createChunkRenderer(() => getRendererChunks(frameworkId)) + } + + async function loadFrameworkChunk(frameworkId: string) { + let promise = frameworkChunks.get(frameworkId) + if (!promise) { + promise = manifestLoader + .loadFramework(frameworkId) + .then((chunk) => { + registerRendererChunk(frameworkId, chunk) + return chunk + }) + .catch((error: unknown) => { + frameworkChunks.delete(frameworkId) + throw error + }) + frameworkChunks.set(frameworkId, promise) + } + + return promise + } + + async function loadAddOnChunk(frameworkId: string, addOnId: string) { + const key = `${frameworkId}:${addOnId}` + let promise = addOnChunks.get(key) + if (!promise) { + promise = manifestLoader + .loadAddOn(frameworkId, addOnId) + .then((chunk) => { + registerRendererChunk(frameworkId, chunk) + return chunk + }) + .catch((error: unknown) => { + addOnChunks.delete(key) + throw error + }) + addOnChunks.set(key, promise) + } + + return promise + } + + function createAddOnFromChunk( + frameworkId: string, + chunk: ManifestAddOnChunk, + ): AddOn { + const { + renderManifestTemplate: _renderManifestTemplate, + hasManifestTemplate: _hasManifestTemplate, + ...compiled + } = chunk + + const addOn: AddOn = { + ...compiled, + getFiles: () => Promise.resolve(Object.keys(compiled.files)), + getFileContents: (path: string) => Promise.resolve(compiled.files[path]), + getDeletedFiles: () => Promise.resolve(compiled.deletedFiles), + } + addOnFrameworkIds.set(addOn, frameworkId) + return addOn + } + + async function materializeAddOn(addOn: AddOn): Promise { + const frameworkId = addOnFrameworkIds.get(addOn) + if (!frameworkId) { + return addOn + } + + const chunk = await loadAddOnChunk(frameworkId, addOn.id) + return createAddOnFromChunk(frameworkId, chunk) + } + + function createLazyAddOn( + frameworkId: string, + metadata: ManifestAddOnMetadata, + ): AddOn { + const addOn: AddOn = { + ...metadata, + files: {}, + deletedFiles: [], + getFiles: async () => { + const chunk = await loadAddOnChunk(frameworkId, metadata.id) + return Object.keys(chunk.files) + }, + getFileContents: async (path: string) => { + const chunk = await loadAddOnChunk(frameworkId, metadata.id) + return chunk.files[path] + }, + getDeletedFiles: async () => { + const chunk = await loadAddOnChunk(frameworkId, metadata.id) + return chunk.deletedFiles + }, + } + + addOnFrameworkIds.set(addOn, frameworkId) + return addOn + } + + function createFrameworkFromMetadata( + metadata: ManifestFrameworkMetadata, + ): Framework { + const addOns = metadata.addOns.map((addOn) => + createLazyAddOn(metadata.id, addOn), + ) + const { addOns: _addOns, ...frameworkMetadata } = metadata + + const framework: Framework = { + ...frameworkMetadata, + getFiles: async () => { + const chunk = await loadFrameworkChunk(metadata.id) + return Object.keys(chunk.base) + }, + getFileContents: async (path: string) => { + const chunk = await loadFrameworkChunk(metadata.id) + return chunk.base[path] + }, + getDeletedFiles: () => Promise.resolve([]), + getAddOns: () => addOns, + } + + registerTemplateRenderer(framework, getRenderer(metadata.id)) + return framework + } + + async function getFrameworks() { + frameworksPromise ??= manifestLoader + .loadCatalog() + .then((catalog) => catalog.frameworks.map(createFrameworkFromMetadata)) + + return frameworksPromise + } + + async function getFrameworkById(id: string) { + const frameworks = await getFrameworks() + const frameworkId = id === 'react-cra' ? 'react' : id + return frameworks.find((framework) => framework.id === frameworkId) + } + + async function getFrameworkByName(name: string) { + const frameworks = await getFrameworks() + return frameworks.find( + (framework) => framework.name.toLowerCase() === name.toLowerCase(), + ) + } + + async function finalizeAddOns( + framework: Framework, + mode: string, + chosenAddOnIDs: Array, + ) { + const finalized = await finalizeBaseAddOns(framework, mode, chosenAddOnIDs) + return Promise.all(finalized.map(materializeAddOn)) + } + + async function createApp(environment: Environment, options: Options) { + await loadFrameworkChunk(options.framework.id) + registerTemplateRenderer( + options.framework, + getRenderer(options.framework.id), + ) + + const chosenAddOns = await Promise.all( + options.chosenAddOns.map(materializeAddOn), + ) + + await createEdgeApp(environment, { + ...options, + chosenAddOns, + }) + } + + return { + getFrameworks, + getFrameworkById, + getFrameworkByName, + getAllAddOns, + finalizeAddOns, + populateAddOnOptionsDefaults, + createApp, + } +} + +export { + CONFIG_FILE, + DEFAULT_PACKAGE_MANAGER, + SUPPORTED_PACKAGE_MANAGERS, + createMemoryEnvironment, + getPackageManagerExecuteCommand, + getPackageManagerInstallCommand, + getPackageManagerScriptCommand, + loadRemoteAddOn, + populateAddOnOptionsDefaults, + translateExecuteCommand, + type MemoryEnvironmentOutput, +} + +export type { + AddOn, + AddOnOption, + AddOnOptions, + AddOnSelectOption, + AddOnSelection, + Environment, + FileBundleHandler, + Framework, + FrameworkDefinition, + Options, + SerializedOptions, + Starter, + StarterCompiled, +} from './types.js' +export type { PackageManager } from './package-manager.js' +export type { + ManifestAddOnChunk, + ManifestAddOnMetadata, + ManifestCatalog, + ManifestFrameworkChunk, + ManifestFrameworkMetadata, + ManifestTemplateModule, + WorkerAddOnManifestModule, + WorkerFrameworkManifestModule, + WorkerManifestModuleLoader, + WorkerManifestLoader, +} from './manifest-types.js' diff --git a/packages/create/tests/edge-import.test.ts b/packages/create/tests/edge-import.test.ts index 814effc7..3857be10 100644 --- a/packages/create/tests/edge-import.test.ts +++ b/packages/create/tests/edge-import.test.ts @@ -29,3 +29,28 @@ describe('@tanstack/create/edge import', () => { expect(edge.getFrameworkById('react')?.id).toBe('react') }) }) + +describe('@tanstack/create/worker import', () => { + afterEach(() => { + for (const moduleName of blockedModules) { + vi.doUnmock(moduleName) + } + vi.resetModules() + }) + + it('does not import Node-only modules or the bundled manifest', async () => { + vi.resetModules() + for (const moduleName of blockedModules) { + vi.doMock(moduleName, () => { + throw new Error(`${moduleName} is unavailable`) + }) + } + vi.doMock('../src/generated/create-manifest.js', () => { + throw new Error('full manifest should not be imported') + }) + + const worker = await import('../src/worker.js') + + expect(typeof worker.createWorkerCreate).toBe('function') + }) +}) diff --git a/packages/create/tests/edge-manifest.test.ts b/packages/create/tests/edge-manifest.test.ts index 090e3e8a..e60fcfa3 100644 --- a/packages/create/tests/edge-manifest.test.ts +++ b/packages/create/tests/edge-manifest.test.ts @@ -105,64 +105,68 @@ describe('@tanstack/create/edge manifest', () => { }) it('generates a React app from the manifest-backed catalog', async () => { - vi.stubGlobal( - 'fetch', - vi.fn(async () => { - return new Response(JSON.stringify({ version: '1.0.0' }), { - status: 200, - }) - }), - ) + try { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + return new Response(JSON.stringify({ version: '1.0.0' }), { + status: 200, + }) + }), + ) - const framework = getFrameworkById('react') - expect(framework).toBeDefined() + const framework = getFrameworkById('react') + expect(framework).toBeDefined() - const featureIds = getAllAddOns(framework!, 'file-router').map( - (addOn) => addOn.id, - ) - expect(featureIds).toContain('tanstack-query') - expect(featureIds).toContain('clerk') - expect(featureIds).toContain('cloudflare') - - const chosenAddOns = await finalizeAddOns(framework!, 'file-router', [ - 'tanstack-query', - 'clerk', - 'cloudflare', - 'biome', - ]) - const addOnOptions = populateAddOnOptionsDefaults(chosenAddOns) - const { environment, output } = createMemoryEnvironment('/worker-app') - - await createApp(environment, { - projectName: 'worker-app', - targetDir: '/worker-app', - framework: framework!, - mode: 'file-router', - typescript: true, - tailwind: true, - packageManager: 'pnpm', - git: false, - install: false, - intent: false, - chosenAddOns, - addOnOptions, - includeExamples: false, - } satisfies Options) - - const packageJSON = JSON.parse(output.files['package.json']) - - expect(packageJSON.scripts.dev).toBe('vite dev --port 3000') - expect(packageJSON.scripts.deploy).toBe( - 'pnpm run build && wrangler deploy', - ) - expect(packageJSON.dependencies).toHaveProperty('@tanstack/react-start') - expect(packageJSON.dependencies).toHaveProperty('@tanstack/react-query') - expect(packageJSON.dependencies).toHaveProperty('@clerk/clerk-react') - expect(packageJSON.devDependencies).toHaveProperty('wrangler') - expect(output.files['wrangler.jsonc']).toContain('tanstack-start-app') - expect(output.files['.env.example']).toContain( - 'VITE_CLERK_PUBLISHABLE_KEY=', - ) - expect(output.files['src/routes/index.tsx']).toContain('createFileRoute') + const featureIds = getAllAddOns(framework!, 'file-router').map( + (addOn) => addOn.id, + ) + expect(featureIds).toContain('tanstack-query') + expect(featureIds).toContain('clerk') + expect(featureIds).toContain('cloudflare') + + const chosenAddOns = await finalizeAddOns(framework!, 'file-router', [ + 'tanstack-query', + 'clerk', + 'cloudflare', + 'biome', + ]) + const addOnOptions = populateAddOnOptionsDefaults(chosenAddOns) + const { environment, output } = createMemoryEnvironment('/worker-app') + + await createApp(environment, { + projectName: 'worker-app', + targetDir: '/worker-app', + framework: framework!, + mode: 'file-router', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns, + addOnOptions, + includeExamples: false, + } satisfies Options) + + const packageJSON = JSON.parse(output.files['package.json']) + + expect(packageJSON.scripts.dev).toBe('vite dev --port 3000') + expect(packageJSON.scripts.deploy).toBe( + 'pnpm run build && wrangler deploy', + ) + expect(packageJSON.dependencies).toHaveProperty('@tanstack/react-start') + expect(packageJSON.dependencies).toHaveProperty('@tanstack/react-query') + expect(packageJSON.dependencies).toHaveProperty('@clerk/clerk-react') + expect(packageJSON.devDependencies).toHaveProperty('wrangler') + expect(output.files['wrangler.jsonc']).toContain('tanstack-start-app') + expect(output.files['.env.example']).toContain( + 'VITE_CLERK_PUBLISHABLE_KEY=', + ) + expect(output.files['src/routes/index.tsx']).toContain('createFileRoute') + } finally { + vi.unstubAllGlobals() + } }) }) diff --git a/packages/create/tests/worker-manifest.test.ts b/packages/create/tests/worker-manifest.test.ts new file mode 100644 index 00000000..1ed428d6 --- /dev/null +++ b/packages/create/tests/worker-manifest.test.ts @@ -0,0 +1,322 @@ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +import ts from 'typescript' +import { describe, expect, it, vi } from 'vitest' + +import { + createApp as createEdgeApp, + createMemoryEnvironment as createEdgeMemoryEnvironment, + finalizeAddOns as finalizeEdgeAddOns, + getFrameworkById as getEdgeFrameworkById, + populateAddOnOptionsDefaults as populateEdgeAddOnOptionsDefaults, +} from '../src/edge.js' +import { + createMemoryEnvironment, + createWorkerCreate, + createWorkerManifestLoader, +} from '../src/worker.js' +import { createBundledWorkerManifestLoader } from '../src/generated/worker/bundled-loader.js' + +import type { Options } from '../src/types.js' +import type { + ManifestCatalog, + WorkerManifestLoader, + WorkerManifestModuleLoader, +} from '../src/manifest-types.js' + +const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') + +function resolveSourceImport(from: string, specifier: string) { + const resolved = resolve(dirname(from), specifier) + const withoutJsExtension = specifier.endsWith('.js') + ? resolved.slice(0, -'.js'.length) + : resolved + const candidates = [ + resolved, + `${withoutJsExtension}.ts`, + resolve(withoutJsExtension, 'index.ts'), + ] + + return candidates.find((candidate) => existsSync(candidate)) +} + +function collectStaticImportGraph(entry: string) { + const visited = new Set() + const pending = [entry] + + while (pending.length) { + const file = pending.pop()! + if (visited.has(file)) { + continue + } + visited.add(file) + + const source = ts.createSourceFile( + file, + readFileSync(file, 'utf8'), + ts.ScriptTarget.Latest, + true, + ts.ScriptKind.TS, + ) + + for (const statement of source.statements) { + if ( + (ts.isImportDeclaration(statement) || + ts.isExportDeclaration(statement)) && + statement.moduleSpecifier && + ts.isStringLiteral(statement.moduleSpecifier) + ) { + const specifier = statement.moduleSpecifier.text + if (!specifier.startsWith('.')) { + continue + } + + const imported = resolveSourceImport(file, specifier) + if (imported) { + pending.push(imported) + } + } + } + } + + return visited +} + +function createTrackedLoader() { + const bundledLoader = createBundledWorkerManifestLoader() + const loaded = { + catalogs: 0, + frameworks: [] as Array, + addOns: [] as Array, + } + + const loader: WorkerManifestLoader = { + async loadCatalog() { + loaded.catalogs++ + return bundledLoader.loadCatalog() + }, + async loadFramework(frameworkId) { + loaded.frameworks.push(frameworkId) + return bundledLoader.loadFramework(frameworkId) + }, + async loadAddOn(frameworkId, addOnId) { + loaded.addOns.push(`${frameworkId}:${addOnId}`) + return bundledLoader.loadAddOn(frameworkId, addOnId) + }, + } + + return { loader, loaded } +} + +describe('@tanstack/create/worker manifest loading', () => { + it('preserves loader method context and retries failed chunk loads', async () => { + const manifestCatalog: ManifestCatalog = { + frameworks: [ + { + id: 'react', + name: 'React', + description: '', + version: '1.0.0', + basePackageJSON: {}, + optionalPackages: {}, + supportedModes: { + 'file-router': { + displayName: 'File Router', + description: '', + forceTypescript: true, + }, + }, + addOns: [ + { + id: 'retry', + name: 'Retry', + description: '', + type: 'add-on', + phase: 'add-on', + modes: ['file-router'], + }, + ], + }, + ], + } + let frameworkLoads = 0 + let addOnLoads = 0 + const moduleLoader: WorkerManifestModuleLoader & { + catalog: ManifestCatalog + } = { + catalog: manifestCatalog, + async loadCatalog() { + return this.catalog + }, + async loadFramework() { + frameworkLoads++ + if (frameworkLoads === 1) { + throw new Error('temporary framework load failure') + } + + return { + framework: { + id: 'react', + base: { + 'package.json': '{}', + }, + }, + renderManifestTemplate: () => '', + } + }, + async loadAddOn() { + addOnLoads++ + if (addOnLoads === 1) { + throw new Error('temporary add-on load failure') + } + + return { + addOn: { + id: 'retry', + name: 'Retry', + description: '', + type: 'add-on', + phase: 'add-on', + modes: ['file-router'], + files: { + 'src/retry.ts': '', + }, + deletedFiles: [], + }, + renderManifestTemplate: () => '', + } + }, + } + const workerCreate = createWorkerCreate( + createWorkerManifestLoader(moduleLoader), + ) + const framework = await workerCreate.getFrameworkById('react') + + await expect(framework!.getFiles()).rejects.toThrow( + 'temporary framework load failure', + ) + await expect(framework!.getFiles()).resolves.toEqual(['package.json']) + + const addOn = framework!.getAddOns()[0]! + await expect(addOn.getFiles()).rejects.toThrow( + 'temporary add-on load failure', + ) + await expect(addOn.getFiles()).resolves.toEqual(['src/retry.ts']) + }) + + it('keeps the worker entrypoint away from generated manifest modules', () => { + const graph = collectStaticImportGraph(resolve(packageDir, 'src/worker.ts')) + const relativeGraph = Array.from(graph).map((file) => + file.slice(packageDir.length + 1).replace(/\\/g, '/'), + ) + const totalBytes = Array.from(graph).reduce( + (sum, file) => sum + statSync(file).size, + 0, + ) + + expect(relativeGraph).not.toContain('src/generated/create-manifest.ts') + expect(relativeGraph.some((file) => file.startsWith('src/generated/'))).toBe( + false, + ) + expect(totalBytes).toBeLessThan(160_000) + }) + + it('loads only selected manifest chunks and matches edge generation', async () => { + try { + vi.stubGlobal( + 'fetch', + vi.fn(async () => { + return new Response(JSON.stringify({ version: '1.0.0' }), { + status: 200, + }) + }), + ) + + const { loader, loaded } = createTrackedLoader() + const workerCreate = createWorkerCreate(loader) + + const framework = await workerCreate.getFrameworkById('react') + expect(framework).toBeDefined() + expect(loaded).toEqual({ + catalogs: 1, + frameworks: [], + addOns: [], + }) + + const featureIds = workerCreate + .getAllAddOns(framework!, 'file-router') + .map((addOn) => addOn.id) + expect(featureIds).toContain('tanstack-query') + expect(featureIds).toContain('cloudflare') + expect(loaded.frameworks).toEqual([]) + expect(loaded.addOns).toEqual([]) + + const chosenAddOns = await workerCreate.finalizeAddOns( + framework!, + 'file-router', + ['tanstack-query', 'cloudflare', 'biome'], + ) + expect(loaded.addOns.sort()).toEqual([ + 'react:biome', + 'react:cloudflare', + 'react:tanstack-query', + ]) + + const addOnOptions = + workerCreate.populateAddOnOptionsDefaults(chosenAddOns) + const { environment, output } = createMemoryEnvironment('/worker-app') + + await workerCreate.createApp(environment, { + projectName: 'worker-app', + targetDir: '/worker-app', + framework: framework!, + mode: 'file-router', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns, + addOnOptions, + includeExamples: false, + } satisfies Options) + + expect(loaded.frameworks).toEqual(['react']) + + const edgeFramework = getEdgeFrameworkById('react') + expect(edgeFramework).toBeDefined() + const edgeChosenAddOns = await finalizeEdgeAddOns( + edgeFramework!, + 'file-router', + ['tanstack-query', 'cloudflare', 'biome'], + ) + const edgeAddOnOptions = + populateEdgeAddOnOptionsDefaults(edgeChosenAddOns) + const { environment: edgeEnvironment, output: edgeOutput } = + createEdgeMemoryEnvironment('/worker-app') + + await createEdgeApp(edgeEnvironment, { + projectName: 'worker-app', + targetDir: '/worker-app', + framework: edgeFramework!, + mode: 'file-router', + typescript: true, + tailwind: true, + packageManager: 'pnpm', + git: false, + install: false, + intent: false, + chosenAddOns: edgeChosenAddOns, + addOnOptions: edgeAddOnOptions, + includeExamples: false, + } satisfies Options) + + expect(output.files).toEqual(edgeOutput.files) + } finally { + vi.unstubAllGlobals() + } + }) +})