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
5 changes: 5 additions & 0 deletions .changeset/native-ts71-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@0no-co/graphqlsp': minor
---

Add an experimental TypeScript 7.1 native API entry for batch GraphQL document diagnostics while preserving the existing tsserver plugin entry.
3 changes: 3 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,6 @@ jobs:

- name: Test
run: pnpm run test:e2e

- name: Test TypeScript 7.1 native API
run: pnpm --filter graphqlsp-native-test test
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,5 @@ dist

packages/graphqlsp/api/*
packages/graphqlsp/api
packages/graphqlsp/native/*
packages/graphqlsp/native
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"prepare": "husky",
"dev": "pnpm --filter @0no-co/graphqlsp dev",
"launch-debug": "./scripts/launch-debug.sh",
"test:native": "pnpm build && pnpm --filter graphqlsp-native-test test",
"test:e2e": "vitest run --no-file-parallelism"
},
"prettier": {
Expand Down
54 changes: 54 additions & 0 deletions packages/graphqlsp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,60 @@ when on a TypeScript file or adding a file like [this](https://github.com/0no-co
> }
> ```

## Experimental TypeScript 7.1 native API

TypeScript 7.1 replaces the legacy in-process compiler API with immutable
native snapshots. GraphQLSP exposes an experimental **batch diagnostics**
adapter at `@0no-co/graphqlsp/native` for
`typescript@7.1.0-dev.20260815.1` and later 7.1 builds with the same unstable
API shape:

```ts
import { createNativeGraphQLSP } from '@0no-co/graphqlsp/native';
import path from 'node:path';
import { buildSchema } from 'graphql';
import * as ast from 'typescript/unstable/ast';
import * as sync from 'typescript/unstable/sync';

const api = new sync.API({ cwd: process.cwd() });
const configFile = path.resolve('tsconfig.json');
const snapshot = api.updateSnapshot({ openProjects: [configFile] });

try {
const project = snapshot.getProject(configFile);
if (!project) throw new Error('Project was not loaded');

const graphqlsp = createNativeGraphQLSP({ sync, ast });
const diagnostics = graphqlsp.getDiagnostics(
project,
'/absolute/path/to/source.ts',
buildSchema('type Query { hello: String! }')
);
} finally {
snapshot.dispose();
api.close();
}
```

The module namespaces are supplied by the caller deliberately: the legacy
plugin continues to use its workspace TypeScript version, while this adapter
uses the exact native TypeScript instance that created the snapshot.

This lane runs GraphQLSP's existing document discovery, static template
interpolation, GraphQL validation, dynamic-interpolation warning, diagnostic
codes, and source-offset mapping. It disables `trackFieldUsage` and
co-located-fragment analysis because those editor/project-wide features still
depend on legacy language-service APIs that the native snapshot API does not
expose.

This is **not an editor plugin replacement yet**. The unstable 7.1 API can read
an LSP-owned snapshot through `API.fromLSPConnection(...)`, but it currently
has no public equivalent of `ts.server.PluginCreateInfo` for injecting custom
diagnostics, hovers, definitions, refactors, and code actions into TypeScript's
editor responses. Keep using the package's existing default entry with
TypeScript 5/6 for those features. A sidecar LSP can use this native batch lane
and publish its returned diagnostics independently.

### Configuration

**Required**
Expand Down
8 changes: 7 additions & 1 deletion packages/graphqlsp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@
"require": "./dist/api.js",
"source": "./src/api.ts"
},
"./native": {
"types": "./dist/native.d.ts",
"import": "./dist/native.mjs",
"require": "./dist/native.js",
"source": "./src/native.ts"
},
"./package.json": "./package.json"
},
"scripts": {
Expand Down Expand Up @@ -55,7 +61,7 @@
},
"peerDependencies": {
"graphql": "^15.5.0 || ^16.0.0 || ^17.0.0",
"typescript": "^5.0.0 || ^6.0.0"
"typescript": "^5.0.0 || ^6.0.0 || >=7.1.0-dev.20260815.1 <7.2.0"
},
"publishConfig": {
"provenance": true
Expand Down
47 changes: 42 additions & 5 deletions packages/graphqlsp/src/ast/checks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,40 @@ const schemaNameCache = new WeakMap<
WeakMap<ts.Symbol, string | null>
>();

// TypeScript 7.1's native API exposes the same information through explicit
// predicates/accessors rather than the legacy TypeScript JS helpers. Keeping
// these tiny compatibility probes here lets document discovery remain shared
// by both hosts without changing the existing tsserver path.
const getUnionOrIntersectionTypes = (
type: ts.Type
): readonly ts.Type[] | null => {
if ('isUnionOrIntersection' in type && type.isUnionOrIntersection()) {
return type.types;
}

const nativeType = type as ts.Type & {
isUnionType?: () => boolean;
isIntersectionType?: () => boolean;
getTypes?: () => readonly ts.Type[];
};
return (nativeType.isUnionType?.() || nativeType.isIntersectionType?.()) &&
nativeType.getTypes
? nativeType.getTypes()
: null;
};

const getStringLiteralValue = (type: ts.Type): string | null => {
if ('isStringLiteral' in type && type.isStringLiteral()) return type.value;
const nativeType = type as ts.Type & {
isStringLiteralType?: () => boolean;
value?: unknown;
};
return nativeType.isStringLiteralType?.() &&
typeof nativeType.value === 'string'
? nativeType.value
: null;
};

const getCached = <T>(
caches: WeakMap<ts.TypeChecker, WeakMap<ts.Symbol, T>>,
checker: ts.TypeChecker,
Expand Down Expand Up @@ -167,11 +201,14 @@ export const getSchemaName = (
const brandTypeSymbol = type.getProperty('__name');
if (brandTypeSymbol) {
const brand = typeChecker.getTypeOfSymbol(brandTypeSymbol);
if (brand.isUnionOrIntersection()) {
const found = brand.types.find(x => x.isStringLiteral());
return found && found.isStringLiteral() ? found.value : null;
} else if (brand.isStringLiteral()) {
return brand.value;
const types = getUnionOrIntersectionTypes(brand);
if (types) {
for (const member of types) {
const value = getStringLiteralValue(member);
if (value !== null) return value;
}
} else {
return getStringLiteralValue(brand);
}
}
}
Expand Down
12 changes: 10 additions & 2 deletions packages/graphqlsp/src/diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -808,10 +808,18 @@ const runDiagnostics = (
);
startChar -= addedCharacters;
endChar -= addedCharacters;
const diagnosticStart = startChar + 1;
let diagnosticEnd = endChar + 1;
while (
diagnosticEnd > diagnosticStart &&
/\s/.test(source.text[diagnosticEnd - 1] || '')
) {
diagnosticEnd--;
}
return {
...x,
start: startChar + 1,
length: endChar - startChar,
start: diagnosticStart,
length: diagnosticEnd - diagnosticStart,
};
}
})
Expand Down
148 changes: 148 additions & 0 deletions packages/graphqlsp/src/native.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import type { GraphQLSchema } from 'graphql';

import { getGraphQLDiagnostics } from './diagnostics';
import { init, reset, ts as activeTypeScript } from './ts';

export interface NativeGraphQLSPProject {
readonly program: {
getSourceFile(fileName: string): unknown;
};
readonly checker: unknown;
}

export interface NativeGraphQLSPConfig {
clientDirectives?: string[];
templateIsCallExpression?: boolean;
}

export interface NativeGraphQLSPDiagnostic {
category: number;
code: number;
file: unknown;
messageText: string;
start: number;
length: number;
}

export interface NativeTypeScriptModules {
/** The `typescript/unstable/sync` module namespace. */
sync: Record<string, unknown>;
/** The `typescript/unstable/ast` module namespace. */
ast: Record<string, unknown>;
}

let nextNativeDiagnosticVersion = 1;
const nativeDiagnosticVersions = new WeakMap<
object,
WeakMap<GraphQLSchema, Map<string, number>>
>();

const getNativeDiagnosticVersion = (
project: NativeGraphQLSPProject,
schema: GraphQLSchema,
config: NativeGraphQLSPConfig
): number => {
const configKey = JSON.stringify({
clientDirectives: config.clientDirectives || [],
templateIsCallExpression: config.templateIsCallExpression ?? true,
});
let projectVersions = nativeDiagnosticVersions.get(project);
if (!projectVersions) {
nativeDiagnosticVersions.set(project, (projectVersions = new WeakMap()));
}
let versions = projectVersions.get(schema);
if (!versions) projectVersions.set(schema, (versions = new Map()));
let version = versions.get(configKey);
if (!version)
versions.set(configKey, (version = nextNativeDiagnosticVersion++));
return version;
};

/**
* Runs GraphQLSP's real document discovery, template resolution, GraphQL
* validation, and source mapping against a TypeScript 7.1 native snapshot.
*
* This is a batch/native API lane. It does not decorate the native language
* service or publish diagnostics to an editor; TypeScript 7.1 does not expose
* a plugin feature-injection contract equivalent to PluginCreateInfo yet.
*/
export function createNativeGraphQLSP(modules: NativeTypeScriptModules) {
const nativeTypeScript = {
...modules.ast,
...modules.sync,
isStringLiteralLike(node: unknown) {
const ast = modules.ast as {
isStringLiteral(node: unknown): boolean;
isNoSubstitutionTemplateLiteral(node: unknown): boolean;
};
return (
ast.isStringLiteral(node) || ast.isNoSubstitutionTemplateLiteral(node)
);
},
forEachChild<T>(
node: { forEachChild(visit: (node: unknown) => T): T },
visit: (node: unknown) => T
) {
return node.forEachChild(visit);
},
};

const getDiagnostics = (
project: NativeGraphQLSPProject,
fileName: string,
schema: GraphQLSchema,
config: NativeGraphQLSPConfig = {}
): NativeGraphQLSPDiagnostic[] => {
const previousTypeScript = activeTypeScript;
init({ typescript: nativeTypeScript } as never);

try {
const program = {
getSourceFile: (name: string) => project.program.getSourceFile(name),
getTypeChecker: () => project.checker,
};
const info = {
config: {
...config,
// These project-wide features still depend on legacy language
// service methods that the native snapshot API doesn't expose.
shouldCheckForColocatedFragments: false,
trackFieldUsage: false,
},
languageService: {
getProgram: () => program,
},
};
const schemaRef = {
current: { schema },
multi: {},
// GraphQLSP's diagnostics cache keys on SchemaRef.version. Native
// callers pass schema objects directly, so assign stable versions per
// project/schema/config tuple to prevent results leaking across native
// snapshots or invocations.
version: getNativeDiagnosticVersion(project, schema, config),
errors: { config: null, load: new Map(), write: new Map() },
outputLocations: new Map(),
sourceLocations: new Map(),
turboLocations: new Map(),
checkStale() {},
};

return (getGraphQLDiagnostics(
fileName,
schemaRef as never,
info as never
) || []) as NativeGraphQLSPDiagnostic[];
} finally {
// The legacy plugin and public core API share a live TypeScript binding.
// Restore it so invoking this adapter cannot permanently alter that path.
if (previousTypeScript) {
init({ typescript: previousTypeScript });
} else {
reset();
}
}
};

return { getDiagnostics };
}
1 change: 1 addition & 0 deletions packages/graphqlsp/src/ts/index.d.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import typescript from 'typescript/lib/tsserverlibrary';
export declare function init(modules: { typescript: typeof typescript }): void;
export declare function reset(): void;
export { typescript as ts };
4 changes: 4 additions & 0 deletions packages/graphqlsp/src/ts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ export var ts;
export function init(modules) {
ts = modules.typescript;
}

export function reset() {
ts = undefined;
}
Loading