From e1480ec0ba298a7fdaf7eec574844e3b8fdd58ee Mon Sep 17 00:00:00 2001 From: Shu Ding Date: Mon, 25 Mar 2024 12:21:49 +0100 Subject: [PATCH] improve tsplugin --- .../next/src/server/typescript/constant.ts | 9 +- packages/next/src/server/typescript/index.ts | 58 ++++++- .../typescript/rules/server-boundary.ts | 152 ++++++++++++++++++ .../src/server/typescript/rules/server.ts | 35 +++- packages/next/src/server/typescript/utils.ts | 34 +++- 5 files changed, 270 insertions(+), 18 deletions(-) create mode 100644 packages/next/src/server/typescript/rules/server-boundary.ts diff --git a/packages/next/src/server/typescript/constant.ts b/packages/next/src/server/typescript/constant.ts index 0622bc3f13674..cd0ba22138219 100644 --- a/packages/next/src/server/typescript/constant.ts +++ b/packages/next/src/server/typescript/constant.ts @@ -2,12 +2,14 @@ export const NEXT_TS_ERRORS = { INVALID_SERVER_API: 71001, INVALID_ENTRY_EXPORT: 71002, INVALID_OPTION_VALUE: 71003, - MISPLACED_CLIENT_ENTRY: 71004, + MISPLACED_ENTRY_DIRECTIVE: 71004, INVALID_PAGE_PROP: 71005, INVALID_CONFIG_OPTION: 71006, INVALID_CLIENT_ENTRY_PROP: 71007, INVALID_METADATA_EXPORT: 71008, INVALID_ERROR_COMPONENT: 71009, + INVALID_ENTRY_DIRECTIVE: 71010, + INVALID_SERVER_ENTRY_RETURN: 71011, } export const ALLOWED_EXPORTS = [ @@ -40,5 +42,10 @@ export const DISALLOWED_SERVER_REACT_APIS: string[] = [ 'useOptimistic', ] +export const DISALLOWED_SERVER_REACT_DOM_APIS: string[] = [ + 'useFormStatus', + 'useFormState', +] + export const ALLOWED_PAGE_PROPS = ['params', 'searchParams'] export const ALLOWED_LAYOUT_PROPS = ['params', 'children'] diff --git a/packages/next/src/server/typescript/index.ts b/packages/next/src/server/typescript/index.ts index ea3c61e1ace76..97066ff0acdaa 100644 --- a/packages/next/src/server/typescript/index.ts +++ b/packages/next/src/server/typescript/index.ts @@ -10,7 +10,7 @@ import { init, - getIsClientEntry, + getEntryInfo, isAppEntryFile, isDefaultFunctionExport, isPositionInsideNode, @@ -23,6 +23,7 @@ import entryConfig from './rules/config' import serverLayer from './rules/server' import entryDefault from './rules/entry' import clientBoundary from './rules/client-boundary' +import serverBoundary from './rules/server-boundary' import metadata from './rules/metadata' import errorEntry from './rules/error' import type tsModule from 'typescript/lib/tsserverlibrary' @@ -62,7 +63,8 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ if (!isAppEntryFile(fileName)) return prior // If it's a server entry. - if (!getIsClientEntry(fileName)) { + const entryInfo = getEntryInfo(fileName) + if (!entryInfo.client) { // Remove specified entries from completion list prior.entries = serverLayer.filterCompletionsAtPosition(prior.entries) @@ -147,7 +149,8 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ if (!isAppEntryFile(fileName)) return prior // Remove type suggestions for disallowed APIs in server components. - if (!getIsClientEntry(fileName)) { + const entryInfo = getEntryInfo(fileName) + if (!entryInfo.client) { const definitions = info.languageService.getDefinitionAtPosition( fileName, position @@ -176,18 +179,22 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ if (!source) return prior let isClientEntry = false + let isServerEntry = false const isAppEntry = isAppEntryFile(fileName) try { - isClientEntry = getIsClientEntry(fileName, true) + const entryInfo = getEntryInfo(fileName, true) + isClientEntry = entryInfo.client + isServerEntry = entryInfo.server } catch (e: any) { prior.push({ file: source, category: ts.DiagnosticCategory.Error, - code: NEXT_TS_ERRORS.MISPLACED_CLIENT_ENTRY, + code: NEXT_TS_ERRORS.MISPLACED_ENTRY_DIRECTIVE, ...e, }) isClientEntry = false + isServerEntry = false } if (isInsideApp(fileName)) { @@ -202,7 +209,7 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ if (ts.isImportDeclaration(node)) { // import ... if (isAppEntry) { - if (!isClientEntry) { + if (!isClientEntry || isServerEntry) { // Check if it has valid imports in the server layer const diagnostics = serverLayer.getSemanticDiagnosticsForImportDeclaration( @@ -244,6 +251,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ ) ) } + + if (isServerEntry) { + prior.push( + ...serverBoundary.getSemanticDiagnosticsForExportVariableStatement( + source, + node + ) + ) + } } else if (isDefaultFunctionExport(node)) { // export default function ... if (isAppEntry) { @@ -263,6 +279,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ ) ) } + + if (isServerEntry) { + prior.push( + ...serverBoundary.getSemanticDiagnosticsForFunctionExport( + source, + node + ) + ) + } } else if ( ts.isFunctionDeclaration(node) && node.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) @@ -289,6 +314,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ ) ) } + + if (isServerEntry) { + prior.push( + ...serverBoundary.getSemanticDiagnosticsForFunctionExport( + source, + node + ) + ) + } } else if (ts.isExportDeclaration(node)) { // export { ... } if (isAppEntry) { @@ -303,6 +337,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ ) prior.push(...metadataDiagnostics) } + + if (isServerEntry) { + prior.push( + ...serverBoundary.getSemanticDiagnosticsForExportDeclaration( + source, + node + ) + ) + } } }) @@ -311,7 +354,8 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({ // Get definition and link for specific node proxy.getDefinitionAndBoundSpan = (fileName: string, position: number) => { - if (isAppEntryFile(fileName) && !getIsClientEntry(fileName)) { + const entryInfo = getEntryInfo(fileName) + if (isAppEntryFile(fileName) && !entryInfo.client) { const metadataDefinition = metadata.getDefinitionAndBoundSpan( fileName, position diff --git a/packages/next/src/server/typescript/rules/server-boundary.ts b/packages/next/src/server/typescript/rules/server-boundary.ts new file mode 100644 index 0000000000000..f7d30ca76e2ed --- /dev/null +++ b/packages/next/src/server/typescript/rules/server-boundary.ts @@ -0,0 +1,152 @@ +// This module provides intellisense for all exports from `"use server"` directive. + +import { NEXT_TS_ERRORS } from '../constant' +import { getTs, getTypeChecker } from '../utils' +import type tsModule from 'typescript/lib/tsserverlibrary' + +// Check if the type is `Promise`. +function isPromiseType(type: tsModule.Type, typeChecker: tsModule.TypeChecker) { + const typeReferenceType = type as tsModule.TypeReference + if (!typeReferenceType.target) return false + + // target should be Promise or Promise<...> + if ( + !/^Promise(<.+>)?$/.test(typeChecker.typeToString(typeReferenceType.target)) + ) { + return false + } + + return true +} + +function isFunctionReturningPromise( + node: tsModule.Node, + typeChecker: tsModule.TypeChecker, + ts: typeof tsModule +) { + const type = typeChecker.getTypeAtLocation(node) + const signatures = typeChecker.getSignaturesOfType( + type, + ts.SignatureKind.Call + ) + + let isPromise = true + if (signatures.length) { + for (const signature of signatures) { + const returnType = signature.getReturnType() + if (returnType.isUnion()) { + for (const t of returnType.types) { + if (!isPromiseType(t, typeChecker)) { + isPromise = false + break + } + } + } else { + isPromise = isPromiseType(returnType, typeChecker) + } + } + } else { + isPromise = false + } + + return isPromise +} + +const serverBoundary = { + getSemanticDiagnosticsForExportDeclaration( + source: tsModule.SourceFile, + node: tsModule.ExportDeclaration + ) { + const ts = getTs() + const typeChecker = getTypeChecker() + if (!typeChecker) return [] + + const diagnostics: tsModule.Diagnostic[] = [] + + const exportClause = node.exportClause + if (exportClause && ts.isNamedExports(exportClause)) { + for (const e of exportClause.elements) { + if (!isFunctionReturningPromise(e, typeChecker, ts)) { + diagnostics.push({ + file: source, + category: ts.DiagnosticCategory.Error, + code: NEXT_TS_ERRORS.INVALID_SERVER_ENTRY_RETURN, + messageText: `The "use server" file can only export async functions.`, + start: e.getStart(), + length: e.getWidth(), + }) + } + } + } + + return diagnostics + }, + + getSemanticDiagnosticsForExportVariableStatement( + source: tsModule.SourceFile, + node: tsModule.VariableStatement + ) { + const ts = getTs() + + const diagnostics: tsModule.Diagnostic[] = [] + + if (ts.isVariableDeclarationList(node.declarationList)) { + for (const declaration of node.declarationList.declarations) { + const initializer = declaration.initializer + if ( + initializer && + (ts.isArrowFunction(initializer) || + ts.isFunctionDeclaration(initializer) || + ts.isFunctionExpression(initializer)) + ) { + diagnostics.push( + ...serverBoundary.getSemanticDiagnosticsForFunctionExport( + source, + initializer + ) + ) + } else { + diagnostics.push({ + file: source, + category: ts.DiagnosticCategory.Error, + code: NEXT_TS_ERRORS.INVALID_SERVER_ENTRY_RETURN, + messageText: `The "use server" file can only export async functions.`, + start: declaration.getStart(), + length: declaration.getWidth(), + }) + } + } + } + + return diagnostics + }, + + getSemanticDiagnosticsForFunctionExport( + source: tsModule.SourceFile, + node: + | tsModule.FunctionDeclaration + | tsModule.ArrowFunction + | tsModule.FunctionExpression + ) { + const ts = getTs() + const typeChecker = getTypeChecker() + if (!typeChecker) return [] + + const diagnostics: tsModule.Diagnostic[] = [] + + if (!isFunctionReturningPromise(node, typeChecker, ts)) { + diagnostics.push({ + file: source, + category: ts.DiagnosticCategory.Error, + code: NEXT_TS_ERRORS.INVALID_SERVER_ENTRY_RETURN, + messageText: `The "use server" file can only export async functions. Add "async" to the function declaration or return a Promise.`, + start: node.getStart(), + length: node.getWidth(), + }) + } + + return diagnostics + }, +} + +export default serverBoundary diff --git a/packages/next/src/server/typescript/rules/server.ts b/packages/next/src/server/typescript/rules/server.ts index 0abf9c2fe0769..88f88613f5f31 100644 --- a/packages/next/src/server/typescript/rules/server.ts +++ b/packages/next/src/server/typescript/rules/server.ts @@ -1,4 +1,8 @@ -import { DISALLOWED_SERVER_REACT_APIS, NEXT_TS_ERRORS } from '../constant' +import { + DISALLOWED_SERVER_REACT_APIS, + DISALLOWED_SERVER_REACT_DOM_APIS, + NEXT_TS_ERRORS, +} from '../constant' import { getTs } from '../utils' import type tsModule from 'typescript/lib/tsserverlibrary' @@ -38,11 +42,12 @@ const serverLayer = { const diagnostics: tsModule.Diagnostic[] = [] const importPath = node.moduleSpecifier.getText(source!) - if (importPath === "'react'" || importPath === '"react"') { - // Check if it imports "useState" - const importClause = node.importClause - if (importClause) { - const namedBindings = importClause.namedBindings + const importClause = node.importClause + const namedBindings = importClause?.namedBindings + + if (importClause) { + if (/^['"]react['"]$/.test(importPath)) { + // Check if it imports "useState" if (namedBindings && ts.isNamedImports(namedBindings)) { const elements = namedBindings.elements for (const element of elements) { @@ -59,6 +64,24 @@ const serverLayer = { } } } + } else if (/^['"]react-dom['"]$/.test(importPath)) { + // Check if it imports "useFormState" + if (namedBindings && ts.isNamedImports(namedBindings)) { + const elements = namedBindings.elements + for (const element of elements) { + const name = element.name.getText(source!) + if (DISALLOWED_SERVER_REACT_DOM_APIS.includes(name)) { + diagnostics.push({ + file: source, + category: ts.DiagnosticCategory.Error, + code: NEXT_TS_ERRORS.INVALID_SERVER_API, + messageText: `"${name}" is not allowed in Server Components.`, + start: element.name.getStart(), + length: element.name.getWidth(), + }) + } + } + } } } diff --git a/packages/next/src/server/typescript/utils.ts b/packages/next/src/server/typescript/utils.ts index 5c8b819747cbe..41517cad68510 100644 --- a/packages/next/src/server/typescript/utils.ts +++ b/packages/next/src/server/typescript/utils.ts @@ -92,14 +92,15 @@ export const isPageFile = (filePath: string) => { } // Check if a module is a client entry. -export function getIsClientEntry( +export function getEntryInfo( fileName: string, throwOnInvalidDirective?: boolean ) { const source = getSource(fileName) if (source) { - let isClientEntry = false let isDirective = true + let isClientEntry = false + let isServerEntry = false ts.forEachChild(source!, (node) => { if ( @@ -120,13 +121,38 @@ export function getIsClientEntry( throw e } } + } else if (node.expression.text === 'use server') { + if (isDirective) { + isServerEntry = true + } else { + if (throwOnInvalidDirective) { + const e = { + messageText: + 'The `"use server"` directive must be put at the top of the file.', + start: node.expression.getStart(), + length: node.expression.getWidth(), + } + throw e + } + } + } + + if (isClientEntry && isServerEntry) { + const e = { + messageText: + 'Cannot use both "use client" and "use server" directives in the same file.', + start: node.expression.getStart(), + length: node.expression.getWidth(), + } + throw e } } else { isDirective = false } }) - return isClientEntry + return { client: isClientEntry, server: isServerEntry } } - return false + + return { client: false, server: false } }