@@ -94,34 +94,40 @@ export async function loadExternalSchema(service: ExternalGraphQLService, buildD
9494 const schemaFilePath = service . downloadPath ? resolve ( service . downloadPath ) : defaultPath
9595
9696 if ( existsSync ( schemaFilePath ) ) {
97- consola . info ( `[graphql:${ service . name } ] Loading schema from local file: ${ schemaFilePath } ` )
98-
9997 try {
10098 const result = loadSchemaSync ( [ schemaFilePath ] , {
10199 loaders : [ new GraphQLFileLoader ( ) ] ,
102100 } )
103- consola . info ( `[graphql:${ service . name } ] External schema loaded successfully from local file` )
104101 return result
105102 }
106- catch ( localError ) {
107- consola . warn ( `[graphql:${ service . name } ] Failed to load local schema, falling back to remote:` , localError )
103+ catch {
104+ consola . warn ( `[graphql:${ service . name } ] Cached schema invalid, loading from source` )
108105 }
109106 }
110107 }
111108
112- consola . info ( `[graphql:${ service . name } ] Loading external schema from: ${ schemas . join ( ', ' ) } ` )
109+ // Determine appropriate loaders based on schema sources
110+ const hasUrls = schemas . some ( schema => isUrl ( schema ) )
111+ const hasLocalFiles = schemas . some ( schema => ! isUrl ( schema ) )
112+ const loaders = [ ]
113+ if ( hasLocalFiles ) {
114+ loaders . push ( new GraphQLFileLoader ( ) )
115+ }
116+ if ( hasUrls ) {
117+ loaders . push ( new UrlLoader ( ) )
118+ }
119+
120+ if ( loaders . length === 0 ) {
121+ throw new Error ( 'No appropriate loaders found for schema sources' )
122+ }
113123
114124 const result = loadSchemaSync ( schemas , {
115- loaders : [
116- new GraphQLFileLoader ( ) ,
117- new UrlLoader ( ) ,
118- ] ,
125+ loaders,
119126 ...( Object . keys ( headers ) . length > 0 && {
120127 headers,
121128 } ) ,
122129 } )
123130
124- consola . info ( `[graphql:${ service . name } ] External schema loaded successfully` )
125131 return result
126132 }
127133 catch ( error ) {
@@ -130,6 +136,13 @@ export async function loadExternalSchema(service: ExternalGraphQLService, buildD
130136 }
131137}
132138
139+ /**
140+ * Check if a path is a URL (http/https)
141+ */
142+ function isUrl ( path : string ) : boolean {
143+ return path . startsWith ( 'http://' ) || path . startsWith ( 'https://' )
144+ }
145+
133146/**
134147 * Download and save schema from external service
135148 */
@@ -149,6 +162,10 @@ export async function downloadAndSaveSchema(service: ExternalGraphQLService, bui
149162 const headers = typeof service . headers === 'function' ? service . headers ( ) : service . headers || { }
150163 const schemas = Array . isArray ( service . schema ) ? service . schema : [ service . schema ]
151164
165+ // Check if any schemas are local files vs URLs
166+ const hasUrlSchemas = schemas . some ( schema => isUrl ( schema ) )
167+ const hasLocalSchemas = schemas . some ( schema => ! isUrl ( schema ) )
168+
152169 // Determine download behavior based on mode
153170 let shouldDownload = false
154171 const fileExists = existsSync ( schemaFilePath )
@@ -157,10 +174,10 @@ export async function downloadAndSaveSchema(service: ExternalGraphQLService, bui
157174 // Always check for updates (original behavior)
158175 shouldDownload = true
159176
160- if ( fileExists ) {
161- // Compare with remote schema
177+ if ( fileExists && hasUrlSchemas ) {
178+ // Only compare with remote schema if we have URL schemas
162179 try {
163- const remoteSchema = loadSchemaSync ( schemas , {
180+ const remoteSchema = loadSchemaSync ( schemas . filter ( isUrl ) , {
164181 loaders : [ new UrlLoader ( ) ] ,
165182 ...( Object . keys ( headers ) . length > 0 && { headers } ) ,
166183 } )
@@ -180,39 +197,74 @@ export async function downloadAndSaveSchema(service: ExternalGraphQLService, bui
180197 shouldDownload = true
181198 }
182199 }
200+ else if ( fileExists && hasLocalSchemas ) {
201+ // For local schemas, check if source files are newer
202+ const localFiles = schemas . filter ( schema => ! isUrl ( schema ) )
203+ let sourceIsNewer = false
204+
205+ for ( const localFile of localFiles ) {
206+ if ( existsSync ( localFile ) ) {
207+ const { statSync } = await import ( 'node:fs' )
208+ const sourceStats = statSync ( localFile )
209+ const cachedStats = statSync ( schemaFilePath )
210+ if ( sourceStats . mtime > cachedStats . mtime ) {
211+ sourceIsNewer = true
212+ break
213+ }
214+ }
215+ }
216+
217+ if ( ! sourceIsNewer ) {
218+ shouldDownload = false
219+ }
220+ }
183221 }
184222 else if ( downloadMode === true || downloadMode === 'once' ) {
185- // Download only if file doesn't exist (offline-friendly)
223+ // Download/copy only if file doesn't exist (offline-friendly)
186224 shouldDownload = ! fileExists
187225
188- if ( fileExists ) {
189- consola . info ( `[graphql:${ service . name } ] Using cached schema from: ${ schemaFilePath } ` )
190- }
226+ // File exists, will use cached version
191227 }
192228
193229 if ( shouldDownload ) {
194- consola . info ( `[graphql:${ service . name } ] Downloading schema to: ${ schemaFilePath } ` )
195-
196- const schema = loadSchemaSync ( schemas , {
197- loaders : [ new UrlLoader ( ) ] ,
198- ...( Object . keys ( headers ) . length > 0 && { headers } ) ,
199- } )
200-
201- const schemaString = printSchemaWithDirectives ( schema )
202-
203- // Ensure directory exists
204- mkdirSync ( dirname ( schemaFilePath ) , { recursive : true } )
205-
206- // Save schema to file
207- writeFileSync ( schemaFilePath , schemaString , 'utf-8' )
208-
209- consola . success ( `[graphql:${ service . name } ] Schema downloaded and saved successfully` )
230+ if ( hasUrlSchemas && hasLocalSchemas ) {
231+ // Mixed schemas: load both URL and local schemas
232+ const schema = loadSchemaSync ( schemas , {
233+ loaders : [ new GraphQLFileLoader ( ) , new UrlLoader ( ) ] ,
234+ ...( Object . keys ( headers ) . length > 0 && { headers } ) ,
235+ } )
236+
237+ const schemaString = printSchemaWithDirectives ( schema )
238+ mkdirSync ( dirname ( schemaFilePath ) , { recursive : true } )
239+ writeFileSync ( schemaFilePath , schemaString , 'utf-8' )
240+ }
241+ else if ( hasUrlSchemas ) {
242+ // URL schemas: download from remote
243+ const schema = loadSchemaSync ( schemas , {
244+ loaders : [ new UrlLoader ( ) ] ,
245+ ...( Object . keys ( headers ) . length > 0 && { headers } ) ,
246+ } )
247+
248+ const schemaString = printSchemaWithDirectives ( schema )
249+ mkdirSync ( dirname ( schemaFilePath ) , { recursive : true } )
250+ writeFileSync ( schemaFilePath , schemaString , 'utf-8' )
251+ }
252+ else if ( hasLocalSchemas ) {
253+ // Local schemas: copy/merge local files
254+ const schema = loadSchemaSync ( schemas , {
255+ loaders : [ new GraphQLFileLoader ( ) ] ,
256+ } )
257+
258+ const schemaString = printSchemaWithDirectives ( schema )
259+ mkdirSync ( dirname ( schemaFilePath ) , { recursive : true } )
260+ writeFileSync ( schemaFilePath , schemaString , 'utf-8' )
261+ }
210262 }
211263
212264 return schemaFilePath
213265 }
214266 catch ( error ) {
215- consola . error ( `[graphql:${ service . name } ] Failed to download schema:` , error )
267+ consola . error ( `[graphql:${ service . name } ] Failed to download/copy schema:` , error )
216268 return undefined
217269 }
218270}
@@ -248,15 +300,13 @@ export async function generateClientTypes(
248300 outputPath ?: string ,
249301 serviceName ?: string ,
250302) {
251- if ( docs . length === 0 ) {
252- const serviceLabel = serviceName ? `: ${ serviceName } ` : ''
253- consola . info ( `[graphql ${ serviceLabel } ] No client GraphQL files found. Skipping client type generation.` )
303+ // For external services, allow schema-only generation (no documents required)
304+ if ( docs . length === 0 && ! serviceName ) {
305+ consola . info ( ' No client GraphQL files found. Skipping client type generation.' )
254306 return false
255307 }
256308
257309 const serviceLabel = serviceName ? `:${ serviceName } ` : ''
258- consola . info ( `[graphql${ serviceLabel } ] Found ${ docs . length } client GraphQL documents` )
259-
260310 const defaultConfig : CodegenClientConfig | GenericSdkConfig = {
261311 emitLegacyCommonJSImports : false ,
262312 useTypeImports : true ,
@@ -284,10 +334,60 @@ export async function generateClientTypes(
284334 }
285335
286336 const mergedConfig = defu ( defaultConfig , config )
287-
288337 const mergedSdkConfig = defu ( mergedConfig , sdkConfig )
289338
290339 try {
340+ // Schema-only generation (no documents)
341+ if ( docs . length === 0 ) {
342+ const output = await codegen ( {
343+ filename : outputPath || 'client-types.generated.ts' ,
344+ schema : parse ( printSchemaWithDirectives ( schema ) ) ,
345+ documents : [ ] ,
346+ config : mergedConfig ,
347+ plugins : [
348+ { pluginContent : { } } ,
349+ { typescript : { } } ,
350+ ] ,
351+ pluginMap : {
352+ pluginContent : { plugin : pluginContent } ,
353+ typescript : { plugin : typescriptPlugin } ,
354+ } ,
355+ } )
356+
357+ // For schema-only generation, create a generic SDK
358+ const sdkContent = `// THIS FILE IS GENERATED, DO NOT EDIT!
359+ /* eslint-disable eslint-comments/no-unlimited-disable */
360+ /* tslint:disable */
361+ /* eslint-disable */
362+ /* prettier-ignore */
363+
364+ import type { GraphQLResolveInfo } from 'graphql'
365+ export type RequireFields<T, K extends keyof T> = Omit<T, K> & { [P in K]-?: NonNullable<T[P]> }
366+
367+ export interface Requester<C = {}, E = unknown> {
368+ <R, V>(doc: string, vars?: V, options?: C): Promise<R> | AsyncIterable<R>
369+ }
370+
371+ export type Sdk = {
372+ request: <R, V = Record<string, any>>(document: string, variables?: V) => Promise<R>
373+ }
374+
375+ export function getSdk(requester: Requester): Sdk {
376+ return {
377+ request: <R, V = Record<string, any>>(document: string, variables?: V): Promise<R> => {
378+ return requester<R, V>(document, variables)
379+ }
380+ }
381+ }
382+ `
383+
384+ return {
385+ types : output ,
386+ sdk : sdkContent ,
387+ }
388+ }
389+
390+ // Full generation with documents
291391 const output = await codegen ( {
292392 filename : outputPath || 'client-types.generated.ts' ,
293393 schema : parse ( printSchemaWithDirectives ( schema ) ) ,
0 commit comments