diff --git a/.changeset/@graphql-hive_gateway-462-dependencies.md b/.changeset/@graphql-hive_gateway-462-dependencies.md new file mode 100644 index 000000000..3f885ec02 --- /dev/null +++ b/.changeset/@graphql-hive_gateway-462-dependencies.md @@ -0,0 +1,8 @@ +--- +'@graphql-hive/gateway': patch +--- + +dependencies updates: + +- Added dependency [`@graphql-hive/importer@workspace:^` ↗︎](https://www.npmjs.com/package/@graphql-hive/importer/v/workspace:^) (to `dependencies`) +- Removed dependency [`@graphql-mesh/include@^0.2.3` ↗︎](https://www.npmjs.com/package/@graphql-mesh/include/v/0.2.3) (from `dependencies`) diff --git a/.changeset/lemon-zebras-smoke.md b/.changeset/lemon-zebras-smoke.md new file mode 100644 index 000000000..44d863c63 --- /dev/null +++ b/.changeset/lemon-zebras-smoke.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/gateway': patch +--- + +Use `@graphql-hive/importer` for importing configs and transpiling TypeScript files diff --git a/.changeset/plenty-timers-nail.md b/.changeset/plenty-timers-nail.md new file mode 100644 index 000000000..6807b95fc --- /dev/null +++ b/.changeset/plenty-timers-nail.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/gateway': minor +--- + +Point to exact location of syntax error when parsing malformed config files diff --git a/.changeset/strong-ghosts-talk.md b/.changeset/strong-ghosts-talk.md new file mode 100644 index 000000000..709fa5092 --- /dev/null +++ b/.changeset/strong-ghosts-talk.md @@ -0,0 +1,5 @@ +--- +'@graphql-hive/importer': major +--- + +Improving Hive's importing capabilities allowing it to parse TypeScript files diff --git a/.prettierignore b/.prettierignore index c084d1115..017e45f98 100644 --- a/.prettierignore +++ b/.prettierignore @@ -9,3 +9,6 @@ __generated__ .wrangler/ *.Dockerfile /examples/ +/packages/importer/tests/fixtures/syntax-error.ts +/e2e/config-syntax-error/gateway.config.ts +/e2e/config-syntax-error/custom-resolvers.ts diff --git a/e2e/config-syntax-error/config-syntax-error.e2e.ts b/e2e/config-syntax-error/config-syntax-error.e2e.ts new file mode 100644 index 000000000..d168008eb --- /dev/null +++ b/e2e/config-syntax-error/config-syntax-error.e2e.ts @@ -0,0 +1,38 @@ +import { createTenv } from '@internal/e2e'; +import { isCI } from '@internal/testing'; +import { expect, it } from 'vitest'; + +const { gateway, service, gatewayRunner } = createTenv(__dirname); + +it.skipIf( + // for whatever reason docker in CI sometimes (sometimes is the keyword, more than less) + // doesnt provide all the logs and throws errors with weird messages and I dont know where from or why + // see https://github.com/graphql-hive/gateway/actions/runs/12830196184/job/35777821364 + isCI() && gatewayRunner === 'docker', +)( + 'should point to exact location of syntax error when parsing a malformed config', + async () => { + await expect( + gateway({ + supergraph: { + with: 'mesh', + services: [await service('hello')], + }, + runner: { + docker: { + volumes: [ + { + host: 'custom-resolvers.ts', + container: '/gateway/custom-resolvers.ts', + }, + ], + }, + }, + }), + ).rejects.toThrowError( + gatewayRunner === 'bun' || gatewayRunner === 'bun-docker' + ? /error: Expected "{" but found "hello"(.|\n)*\/custom-resolvers.ts:8:11/ + : /SyntaxError \[Error\]: Error transforming .*(\/|\\)custom-resolvers.ts: Unexpected token, expected "{" \(8:11\)/, + ); + }, +); diff --git a/e2e/config-syntax-error/custom-resolvers.ts b/e2e/config-syntax-error/custom-resolvers.ts new file mode 100644 index 000000000..14cf9b54f --- /dev/null +++ b/e2e/config-syntax-error/custom-resolvers.ts @@ -0,0 +1,12 @@ +// @ts-nocheck -- syntax error intentionally + +import { GatewayContext } from '@graphql-hive/gateway'; +import { IResolvers } from '@graphql-tools/utils'; + +export const customResolvers: IResolvers = { + Query: { + bye() hello { + return 'world'; + }, + }, +}; diff --git a/e2e/config-syntax-error/gateway.config.ts b/e2e/config-syntax-error/gateway.config.ts new file mode 100644 index 000000000..285801327 --- /dev/null +++ b/e2e/config-syntax-error/gateway.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from '@graphql-hive/gateway'; +import { customResolvers } from './custom-resolvers'; + +export const gatewayConfig = defineConfig({ + additionalResolvers: [customResolvers], +}); diff --git a/e2e/config-syntax-error/mesh.config.ts b/e2e/config-syntax-error/mesh.config.ts new file mode 100644 index 000000000..1aee7cc83 --- /dev/null +++ b/e2e/config-syntax-error/mesh.config.ts @@ -0,0 +1,22 @@ +import { + defineConfig, + loadGraphQLHTTPSubgraph, +} from '@graphql-mesh/compose-cli'; +import { Opts } from '@internal/testing'; + +const opts = Opts(process.argv); + +export const composeConfig = defineConfig({ + subgraphs: [ + { + sourceHandler: loadGraphQLHTTPSubgraph('hello', { + endpoint: `http://localhost:${opts.getServicePort('hello')}/graphql`, + }), + }, + ], + additionalTypeDefs: /* GraphQL */ ` + extend type Query { + bye: String! + } + `, +}); diff --git a/e2e/config-syntax-error/package.json b/e2e/config-syntax-error/package.json new file mode 100644 index 000000000..eaa4effe3 --- /dev/null +++ b/e2e/config-syntax-error/package.json @@ -0,0 +1,10 @@ +{ + "name": "@e2e/config-syntax-error", + "private": true, + "dependencies": { + "@graphql-mesh/compose-cli": "^1.2.13", + "@graphql-tools/utils": "^10.7.2", + "graphql": "^16.9.0", + "tslib": "^2.8.1" + } +} diff --git a/e2e/config-syntax-error/services/hello.ts b/e2e/config-syntax-error/services/hello.ts new file mode 100644 index 000000000..9cc2b9548 --- /dev/null +++ b/e2e/config-syntax-error/services/hello.ts @@ -0,0 +1,23 @@ +import { createServer } from 'http'; +import { Opts } from '@internal/testing'; +import { createSchema, createYoga } from 'graphql-yoga'; + +const opts = Opts(process.argv); + +createServer( + createYoga({ + maskedErrors: false, + schema: createSchema({ + typeDefs: /* GraphQL */ ` + type Query { + hello: String! + } + `, + resolvers: { + Query: { + hello: () => 'world', + }, + }, + }), + }), +).listen(opts.getServicePort('hello')); diff --git a/e2e/tsconfig-paths/tsconfig-paths.e2e.ts b/e2e/tsconfig-paths/tsconfig-paths.e2e.ts index fce93c691..345c99a96 100644 --- a/e2e/tsconfig-paths/tsconfig-paths.e2e.ts +++ b/e2e/tsconfig-paths/tsconfig-paths.e2e.ts @@ -13,7 +13,7 @@ it.skipIf(gatewayRunner.includes('bun'))('should start gateway', async () => { 'type Query { hello: String }', ), env: { - MESH_INCLUDE_TSCONFIG_SEARCH_PATH: 'tsconfig-paths.tsconfig.json', + HIVE_IMPORTER_TSCONFIG_SEARCH_PATH: 'tsconfig-paths.tsconfig.json', }, runner: { docker: { diff --git a/internal/e2e/src/tenv.ts b/internal/e2e/src/tenv.ts index 69c3bdd1c..c572d4b2e 100644 --- a/internal/e2e/src/tenv.ts +++ b/internal/e2e/src/tenv.ts @@ -526,7 +526,6 @@ export function createTenv(cwd: string): Tenv { path.resolve(__project, 'packages', 'gateway', 'src', 'bin.ts'), ...getFullArgs(), ); - leftoverStack.use(proc); break; } case 'bin': { @@ -883,28 +882,38 @@ export function createTenv(cwd: string): Tenv { getStats() { throw new Error('Cannot get stats of a container.'); }, - [DisposableSymbols.asyncDispose]() { + async [DisposableSymbols.asyncDispose]() { if (ctrl.signal.aborted) { // noop if already disposed - return undefined as unknown as Promise; + return; } ctrl.abort(); - return ctr.stop({ t: 0, signal: 'SIGTERM' }); + await ctr.stop({ t: 0, signal: 'SIGTERM' }); }, }; - leftoverStack.use(container); // verify that the container has started - await setTimeout(interval); - try { - await ctr.inspect(); - } catch (err) { - if (Object(err).statusCode === 404) { - throw new DockerError('Container was not started', container); + let startCheckRetries = 3; + while (startCheckRetries) { + await setTimeout(interval); + try { + await ctr.inspect({ abortSignal: ctrl.signal }); + break; + } catch (err) { + // we dont use the err.statusCode because it doesnt work in CI, why? no clue + if (/no such container/i.test(String(err))) { + if (!--startCheckRetries) { + throw new DockerError('Container did not start', container, err); + } + continue; + } + throw new DockerError(String(err), container, err); } - throw err; } + // we add the container to the stack only if it started + leftoverStack.use(container); + // wait for healthy if (healthcheck.length > 0) { while (!ctrl.signal.aborted) { @@ -915,10 +924,11 @@ export function createTenv(cwd: string): Tenv { } = await ctr.inspect({ abortSignal: ctrl.signal }); status = Health?.Status ? String(Health?.Status) : ''; } catch (err) { - if (Object(err).statusCode === 404) { - throw new DockerError('Container was not started', container); + if (/no such container/i.test(String(err))) { + ctrl.abort(); // container died so no need to dispose of it (see async dispose implementation) + throw new DockerError('Container died', container, err); } - throw err; + throw new DockerError(String(err), container, err); } if (status === 'none') { @@ -926,10 +936,11 @@ export function createTenv(cwd: string): Tenv { throw new DockerError( 'Container has "none" health status, but has a healthcheck', container, + null, ); } else if (status === 'unhealthy') { await container[DisposableSymbols.asyncDispose](); - throw new DockerError('Container is unhealthy', container); + throw new DockerError('Container is unhealthy', container, null); } else if (status === 'healthy') { break; } else if (status === 'starting') { @@ -938,6 +949,7 @@ export function createTenv(cwd: string): Tenv { throw new DockerError( `Unknown health status "${status}"`, container, + null, ); } } @@ -1010,10 +1022,12 @@ class DockerError extends Error { constructor( public override message: string, container: Container, + cause: unknown, ) { super(); this.name = 'DockerError'; this.message = message + '\n' + container.getStd('both'); + this.cause = cause; } } diff --git a/internal/proc/src/index.ts b/internal/proc/src/index.ts index 32e963fce..7b8f250e6 100644 --- a/internal/proc/src/index.ts +++ b/internal/proc/src/index.ts @@ -107,12 +107,19 @@ export function spawn( mem: parseFloat(mem!) * 0.001, // KB to MB }; }, - [DisposableSymbols.asyncDispose]: () => { - const childPid = child.pid; - if (childPid && !exited) { - return terminate(childPid); + [DisposableSymbols.asyncDispose]: async () => { + if (exited) { + // there's nothing to dispose since the process already exitted (error or not) + return Promise.resolve(); } - return waitForExit; + if (child.pid) { + await terminate(child.pid); + } + child.kill(); + await waitForExit.catch(() => { + // we dont care about if abnormal exit code when disposing + // specifically in Windows, exit code is always 1 when killing a live process + }); }, }; stack?.use(proc); diff --git a/packages/gateway/package.json b/packages/gateway/package.json index e92925f4f..c72c38729 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -57,12 +57,12 @@ "@commander-js/extra-typings": "^13.0.0", "@envelop/core": "^5.0.2", "@graphql-hive/gateway-runtime": "workspace:^", + "@graphql-hive/importer": "workspace:^", "@graphql-mesh/cache-cfw-kv": "^0.104.0", "@graphql-mesh/cache-localforage": "^0.103.0", "@graphql-mesh/cache-redis": "^0.103.0", "@graphql-mesh/cross-helpers": "^0.4.9", "@graphql-mesh/hmac-upstream-signature": "workspace:^", - "@graphql-mesh/include": "^0.2.3", "@graphql-mesh/plugin-deduplicate-request": "^0.103.0", "@graphql-mesh/plugin-http-cache": "^0.103.0", "@graphql-mesh/plugin-jit": "^0.1.0", diff --git a/packages/gateway/rollup.config.binary.js b/packages/gateway/rollup.config.binary.js index 766e439bd..8bc3a0348 100644 --- a/packages/gateway/rollup.config.binary.js +++ b/packages/gateway/rollup.config.binary.js @@ -92,7 +92,7 @@ return module.exports; JSON.stringify(__MODULES_HASH__), ); - // replace all "graphql*" requires to use the packed deps (the new require will invoke @graphql-mesh/include/hooks) + // replace all "graphql*" requires to use the packed deps (the new require will invoke @graphql-hive/importer/hooks) for (const [match, path] of code.matchAll(/require\('(graphql.*)'\)/g)) { code = code.replace( match, @@ -101,13 +101,13 @@ return module.exports; ); } - // replace the @graphql-mesh/include/hooks register to use the absolute path of the packed deps + // replace the @graphql-hive/importer/hooks register to use the absolute path of the packed deps const includeHooksRegisterDest = - /register\(\s*'@graphql-mesh\/include\/hooks'/g; // intentionally no closing bracked because there's more arguments + /register\(\s*'@graphql-hive\/importer\/hooks'/g; // intentionally no closing bracked because there's more arguments if (includeHooksRegisterDest.test(code)) { code = code.replaceAll( includeHooksRegisterDest, - `register(require('node:url').pathToFileURL(require('node:path').join(globalThis.__PACKED_DEPS_PATH__, '@graphql-mesh', 'include', 'hooks.mjs'))`, + `register(require('node:url').pathToFileURL(require('node:path').join(globalThis.__PACKED_DEPS_PATH__, '@graphql-hive', 'importer', 'hooks.mjs'))`, ); } else { throw new Error( diff --git a/packages/gateway/rollup.config.js b/packages/gateway/rollup.config.js index cae3c79d7..4bd5faadf 100644 --- a/packages/gateway/rollup.config.js +++ b/packages/gateway/rollup.config.js @@ -43,8 +43,7 @@ const deps = { 'node_modules/@graphql-hive/gateway-runtime/index': '../runtime/src/index.ts', 'node_modules/@graphql-mesh/fusion-runtime/index': '../fusion-runtime/src/index.ts', - 'node_modules/@graphql-mesh/include/hooks': - '../../node_modules/@graphql-mesh/include/esm/hooks.js', + 'node_modules/@graphql-hive/importer/hooks': '../importer/src/hooks.ts', // default transports should be in the container 'node_modules/@graphql-mesh/transport-common/index': @@ -141,7 +140,7 @@ function packagejson() { const mjsFile = path .basename(bundle.fileName, '.mjs') .replace(/\\/g, '/'); - // if the bundled file is not "index", then it's an exports path (like with @graphql-mesh/include/hooks) + // if the bundled file is not "index", then it's an package.json exports path pkg['exports'] = { [`./${mjsFile}`]: `./${bundledFile}` }; } this.emitFile({ diff --git a/packages/gateway/src/bin.ts b/packages/gateway/src/bin.ts index de8c400d5..176940bef 100644 --- a/packages/gateway/src/bin.ts +++ b/packages/gateway/src/bin.ts @@ -2,13 +2,13 @@ import 'dotenv/config'; // inject dotenv options to process.env import module from 'node:module'; -import type { InitializeData } from '@graphql-mesh/include/hooks'; +import type { InitializeData } from '@graphql-hive/importer/hooks'; import { DefaultLogger } from '@graphql-mesh/utils'; import { enableModuleCachingIfPossible, handleNodeWarnings, run } from './cli'; // @inject-version globalThis.__VERSION__ here -module.register('@graphql-mesh/include/hooks', { +module.register('@graphql-hive/importer/hooks', { parentURL: // @ts-ignore bob will complain when bundling for cjs import.meta.url, diff --git a/packages/importer/README.md b/packages/importer/README.md new file mode 100644 index 000000000..fb8ff83f1 --- /dev/null +++ b/packages/importer/README.md @@ -0,0 +1,7 @@ +# @graphql-hive/importer + +Used to improve Hive's importing capabilities allowing it to parse TypeScript files. + +Please note that `get-tsconfig` and `sucrase` are **intentionally** inside devDependencies at the [package.json](/packages/mporter/package.json) because we want to bundle them in. + +[pkgroll will bundle all devDependencies that are used in the source code.](https://github.com/privatenumber/pkgroll?tab=readme-ov-file#dependency-bundling--externalization) diff --git a/packages/importer/package.json b/packages/importer/package.json new file mode 100644 index 000000000..d7c413c39 --- /dev/null +++ b/packages/importer/package.json @@ -0,0 +1,58 @@ +{ + "name": "@graphql-hive/importer", + "version": "0.0.0", + "type": "module", + "repository": { + "type": "git", + "url": "git+https://github.com/graphql-hive/gateway.git", + "directory": "packages/runtime" + }, + "author": { + "email": "contact@the-guild.dev", + "name": "The Guild", + "url": "https://the-guild.dev" + }, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "main": "./dist/index.js", + "exports": { + ".": { + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + }, + "import": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + } + }, + "./hooks": { + "require": { + "types": "./dist/hooks.d.cts", + "default": "./dist/hooks.cjs" + }, + "import": { + "types": "./dist/hooks.d.ts", + "default": "./dist/hooks.js" + } + }, + "./package.json": "./package.json" + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "build": "pkgroll --clean-dist", + "prepack": "yarn build" + }, + "devDependencies": { + "get-tsconfig": "^4.7.6", + "glob": "^11.0.0", + "pkgroll": "2.6.1", + "sucrase": "^3.35.0" + }, + "sideEffects": false +} diff --git a/packages/importer/src/debug.ts b/packages/importer/src/debug.ts new file mode 100644 index 000000000..4b38c5a44 --- /dev/null +++ b/packages/importer/src/debug.ts @@ -0,0 +1,9 @@ +export const isDebug = ['1', 'y', 'yes', 't', 'true'].includes( + String(process.env['DEBUG']), +); + +export function debug(msg: string) { + if (isDebug) { + process.stderr.write(`[${new Date().toISOString()}] HOOKS ${msg}\n`); + } +} diff --git a/packages/importer/src/hooks.ts b/packages/importer/src/hooks.ts new file mode 100644 index 000000000..a5633e288 --- /dev/null +++ b/packages/importer/src/hooks.ts @@ -0,0 +1,209 @@ +/** + * ONLY FOR NODE + * + * Register and use with: + * + * ```sh + * node --import @graphql-hive/importer/hooks + * ``` + * + * [Read more about Customization Hooks.](https://nodejs.org/api/module.html#customization-hooks) + */ + +import module from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { createPathsMatcher, getTsconfig } from 'get-tsconfig'; +import { debug } from './debug'; +import { transpileTypeScriptFile } from './transpile'; + +const resolveFilename: (path: string) => string = + // @ts-expect-error property '_resolveFilename' does exist on type Module + module['_resolveFilename']; + +let packedDepsPath = ''; + +let pathsMatcher: ((specifier: string) => string[]) | null; + +export interface InitializeData { + /** + * Packed deps will be checked first, and enforced if present, during module resolution. + * This allows us to consistently use the same module instance even if multiple are installed by the user. + */ + packedDepsPath?: string; + /** + * tsconfig search path for registering tsconfig paths. + * + * @default process.env.HIVE_IMPORTER_TSCONFIG_SEARCH_PATH || 'tsconfig.json' + */ + tsconfigSearchPath?: string; +} + +export const initialize: module.InitializeHook = ( + data = {}, +) => { + if (data.packedDepsPath) { + packedDepsPath = data.packedDepsPath; + debug(`Packed dependencies available at "${packedDepsPath}"`); + } + const tsconfig = getTsconfig( + undefined, + data.tsconfigSearchPath || + process.env['HIVE_IMPORTER_TSCONFIG_SEARCH_PATH'] || + 'tsconfig.json', + ); + if (tsconfig) { + debug(`tsconfig found at "${tsconfig.path}"`); + pathsMatcher = createPathsMatcher(tsconfig); + } +}; + +function fixSpecifier(specifier: string, context: module.ResolveHookContext) { + if (path.sep === '\\') { + if (context.parentURL != null && context.parentURL[1] === ':') { + context.parentURL = pathToFileURL( + context.parentURL.replaceAll('/', '\\'), + ).toString(); + } + if (specifier[1] === ':' && specifier[2] === '/') { + specifier = specifier.replaceAll('/', '\\'); + } + if (specifier.startsWith('file://')) { + specifier = fileURLToPath(specifier); + } + if ( + !specifier.startsWith('.') && + !specifier.startsWith('file:') && + specifier[1] === ':' + ) { + specifier = pathToFileURL(specifier).toString(); + } + } + if ( + specifier.startsWith('node_modules/') || + specifier.startsWith('node_modules\\') + ) { + specifier = specifier + .replace('node_modules/', '') + .replace('node_modules\\', '') + .replace(/\\/g, '/'); + } + return specifier; +} + +export const resolve: module.ResolveHook = async ( + specifier, + context, + nextResolve, +) => { + specifier = fixSpecifier(specifier, context); + + if (specifier.startsWith('node:')) { + return nextResolve(specifier, context); + } + if (module.builtinModules.includes(specifier)) { + return nextResolve(specifier, context); + } + + if (!specifier.startsWith('.') && packedDepsPath) { + try { + debug( + `Trying packed dependency "${specifier}" for "${context.parentURL?.toString() || '.'}"`, + ); + const resolved = resolveFilename(path.join(packedDepsPath, specifier)); + debug(`Possible packed dependency "${specifier}" to "${resolved}"`); + return await nextResolve(fixSpecifier(resolved, context), context); + } catch { + // noop + } + } + + try { + debug(`Trying default resolve for "${specifier}"`); + return await nextResolve(specifier, context); + } catch (e) { + try { + debug( + `Trying default resolve for "${specifier}" failed; trying alternatives`, + ); + const specifierWithoutJs = specifier.endsWith('.js') + ? specifier.slice(0, -3) + : specifier; + const specifierWithTs = specifierWithoutJs + '.ts'; // TODO: .mts or .cts + debug(`Trying "${specifierWithTs}"`); + return await nextResolve(fixSpecifier(specifierWithTs, context), context); + } catch (e) { + try { + return await nextResolve( + fixSpecifier(resolveFilename(specifier), context), + context, + ); + } catch { + try { + const specifierWithoutJs = specifier.endsWith('.js') + ? specifier.slice(0, -3) + : specifier; + // usual filenames tried, could be a .ts file? + return await nextResolve( + fixSpecifier( + resolveFilename( + specifierWithoutJs + '.ts', // TODO: .mts or .cts? + ), + context, + ), + context, + ); + } catch { + // not a .ts file, try the tsconfig paths if available + if (pathsMatcher) { + for (const possiblePath of pathsMatcher(specifier)) { + try { + return await nextResolve( + fixSpecifier(resolveFilename(possiblePath), context), + context, + ); + } catch { + try { + const possiblePathWithoutJs = possiblePath.endsWith('.js') + ? possiblePath.slice(0, -3) + : possiblePath; + // the tsconfig path might point to a .ts file, try it too + return await nextResolve( + fixSpecifier( + resolveFilename( + possiblePathWithoutJs + '.ts', // TODO: .mts or .cts? + ), + context, + ), + context, + ); + } catch { + // noop + } + } + } + } + } + } + } + + // none of the alternatives worked, fail with original error + throw e; + } +}; + +export const load: module.LoadHook = async (url, context, nextLoad) => { + if (path.sep === '\\' && !url.startsWith('file:') && url[1] === ':') { + debug(`Fixing Windows path at "${url}"`); + url = `file:///${url.replace(/\\/g, '/')}`; + } + if (/\.(m|c)?ts$/.test(url)) { + const { format, source } = await transpileTypeScriptFile(url); + return { + format, + source, + shortCircuit: true, + }; + } + return nextLoad(url, context); +}; diff --git a/packages/importer/src/index.ts b/packages/importer/src/index.ts new file mode 100644 index 000000000..45c6889c4 --- /dev/null +++ b/packages/importer/src/index.ts @@ -0,0 +1 @@ +export { transpileTypeScriptFile } from './transpile'; diff --git a/packages/importer/src/transpile.ts b/packages/importer/src/transpile.ts new file mode 100644 index 000000000..2787d6dcc --- /dev/null +++ b/packages/importer/src/transpile.ts @@ -0,0 +1,43 @@ +import fs from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { transform, type Transform } from 'sucrase'; +import { debug } from './debug'; + +export interface Transpiled { + format: 'commonjs' | 'module'; + source: string; +} + +export async function transpileTypeScriptFile( + url: string, +): Promise { + debug(`Transpiling TypeScript file at "${url}"`); + const filePath = fileURLToPath(url); + let source: string; + try { + source = await fs.readFile(filePath, 'utf8'); + } catch (e) { + throw new Error(`Failed to read file at "${url}"; ${Object(e).stack || e}`); + } + let format: 'module' | 'commonjs'; + if (/\.ts$/.test(url)) { + format = 'module'; + } else if (/\.mts$/.test(url)) { + format = 'module'; + } else if (/\.cts$/.test(url)) { + format = 'commonjs'; + } else { + throw new Error( + `Format of "${url}" could not be detected, is it a TypeScript file?`, + ); + } + const transforms: Transform[] = ['typescript']; + if (format === 'commonjs') { + transforms.push('imports'); + } + const { code } = transform(source, { transforms, filePath }); + return { + format, + source: code, + }; +} diff --git a/packages/importer/tests/fixtures/basic.cts b/packages/importer/tests/fixtures/basic.cts new file mode 100644 index 000000000..5a1156ec0 --- /dev/null +++ b/packages/importer/tests/fixtures/basic.cts @@ -0,0 +1,2 @@ +const str: string = 'ing'; +module.exports = { str }; diff --git a/packages/importer/tests/fixtures/basic.ts b/packages/importer/tests/fixtures/basic.ts new file mode 100644 index 000000000..d62f5a5e4 --- /dev/null +++ b/packages/importer/tests/fixtures/basic.ts @@ -0,0 +1 @@ +export const str: string = 'ing'; diff --git a/packages/importer/tests/fixtures/syntax-error.ts b/packages/importer/tests/fixtures/syntax-error.ts new file mode 100644 index 000000000..985146782 --- /dev/null +++ b/packages/importer/tests/fixtures/syntax-error.ts @@ -0,0 +1,2 @@ +// @ts-nocheck -- syntax error intentionally +const str ing: string = '?'; diff --git a/packages/importer/tests/transpile.spec.ts b/packages/importer/tests/transpile.spec.ts new file mode 100644 index 000000000..2c2ec76b4 --- /dev/null +++ b/packages/importer/tests/transpile.spec.ts @@ -0,0 +1,35 @@ +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { transpileTypeScriptFile } from '../src/transpile'; + +describe.skipIf(process.env['LEAK_TEST'])('Transpile', () => { + it('should transpile basic typescript file', async () => { + const url = pathToFileURL(path.join(__dirname, 'fixtures', 'basic.ts')); + const { format, source } = await transpileTypeScriptFile(url.toString()); + expect(format).toMatchInlineSnapshot(`"module"`); + expect(source.trim()).toMatchInlineSnapshot(`"export const str = 'ing';"`); + }); + + it.skipIf( + // bun has issues with the snapshot. it looks exactly the same but bun claims it doesnt match + globalThis.Bun, + )('should transpile basic typescript commonjs file', async () => { + const url = pathToFileURL(path.join(__dirname, 'fixtures', 'basic.cts')); + const { format, source } = await transpileTypeScriptFile(url.toString()); + expect(format).toMatchInlineSnapshot(`"commonjs"`); + expect(source.trim()).toMatchInlineSnapshot(` + ""use strict";const str = 'ing'; + module.exports = { str };"`); + }); + + it('should fail transpiling typescript file with syntax error and file location', async () => { + const url = pathToFileURL( + path.join(__dirname, 'fixtures', 'syntax-error.ts'), + ); + await expect(transpileTypeScriptFile(url.toString())).rejects.toThrowError( + // we include the starting forwardslash and the project path because we want to make sure the absolute path is reported + /Error transforming \/(.*)\/packages\/importer\/tests\/fixtures\/syntax-error.ts: Unexpected token, expected ";" \(2:11\)/, + ); + }); +}); diff --git a/tsconfig.json b/tsconfig.json index 46e06d44a..f145d6173 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -21,6 +21,8 @@ "./internal/testing/src/to-be-similar-gql-doc.ts" ], "@internal/testing/fixtures/*": ["./internal/testing/fixtures/*"], + "@graphql-hive/importer": ["./packages/importer/src/index.ts"], + "@graphql-hive/importer/*": ["./packages/importer/src/*"], "@graphql-hive/gateway": ["./packages/gateway/src/index.ts"], "@graphql-hive/gateway-runtime": ["./packages/runtime/src/index.ts"], "@graphql-mesh/fusion-runtime": [ @@ -76,5 +78,10 @@ "./packages/**/rollup.config.*", "./e2e", "./bench" + ], + "exclude": [ + "./packages/importer/tests/fixtures/syntax-error.ts", + "./e2e/config-syntax-error/gateway.config.ts", + "./e2e/config-syntax-error/custom-resolvers.ts" ] } diff --git a/yarn.lock b/yarn.lock index b7abb5c72..1d106f97c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2355,6 +2355,17 @@ __metadata: languageName: unknown linkType: soft +"@e2e/config-syntax-error@workspace:e2e/config-syntax-error": + version: 0.0.0-use.local + resolution: "@e2e/config-syntax-error@workspace:e2e/config-syntax-error" + dependencies: + "@graphql-mesh/compose-cli": "npm:^1.2.13" + "@graphql-tools/utils": "npm:^10.7.2" + graphql: "npm:^16.9.0" + tslib: "npm:^2.8.1" + languageName: unknown + linkType: soft + "@e2e/extra-fields@workspace:e2e/extra-fields": version: 0.0.0-use.local resolution: "@e2e/extra-fields@workspace:e2e/extra-fields" @@ -3245,12 +3256,12 @@ __metadata: "@commander-js/extra-typings": "npm:^13.0.0" "@envelop/core": "npm:^5.0.2" "@graphql-hive/gateway-runtime": "workspace:^" + "@graphql-hive/importer": "workspace:^" "@graphql-mesh/cache-cfw-kv": "npm:^0.104.0" "@graphql-mesh/cache-localforage": "npm:^0.103.0" "@graphql-mesh/cache-redis": "npm:^0.103.0" "@graphql-mesh/cross-helpers": "npm:^0.4.9" "@graphql-mesh/hmac-upstream-signature": "workspace:^" - "@graphql-mesh/include": "npm:^0.2.3" "@graphql-mesh/plugin-deduplicate-request": "npm:^0.103.0" "@graphql-mesh/plugin-http-cache": "npm:^0.103.0" "@graphql-mesh/plugin-jit": "npm:^0.1.0" @@ -3310,6 +3321,17 @@ __metadata: languageName: unknown linkType: soft +"@graphql-hive/importer@workspace:^, @graphql-hive/importer@workspace:packages/importer": + version: 0.0.0-use.local + resolution: "@graphql-hive/importer@workspace:packages/importer" + dependencies: + get-tsconfig: "npm:^4.7.6" + glob: "npm:^11.0.0" + pkgroll: "npm:2.6.1" + sucrase: "npm:^3.35.0" + languageName: unknown + linkType: soft + "@graphql-hive/yoga@npm:^0.39.2": version: 0.39.2 resolution: "@graphql-hive/yoga@npm:0.39.2" @@ -3504,7 +3526,7 @@ __metadata: languageName: unknown linkType: soft -"@graphql-mesh/include@npm:^0.2.10, @graphql-mesh/include@npm:^0.2.3": +"@graphql-mesh/include@npm:^0.2.10": version: 0.2.10 resolution: "@graphql-mesh/include@npm:0.2.10" dependencies: