Skip to content

Commit

Permalink
improve tsplugin
Browse files Browse the repository at this point in the history
  • Loading branch information
shuding committed Mar 25, 2024
1 parent 2746990 commit e1480ec
Show file tree
Hide file tree
Showing 5 changed files with 270 additions and 18 deletions.
9 changes: 8 additions & 1 deletion packages/next/src/server/typescript/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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']
58 changes: 51 additions & 7 deletions packages/next/src/server/typescript/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

import {
init,
getIsClientEntry,
getEntryInfo,
isAppEntryFile,
isDefaultFunctionExport,
isPositionInsideNode,
Expand All @@ -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'
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)) {
Expand All @@ -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(
Expand Down Expand Up @@ -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) {
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -303,6 +337,15 @@ export const createTSPlugin: tsModule.server.PluginModuleFactory = ({
)
prior.push(...metadataDiagnostics)
}

if (isServerEntry) {
prior.push(
...serverBoundary.getSemanticDiagnosticsForExportDeclaration(
source,
node
)
)
}
}
})

Expand All @@ -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
Expand Down
152 changes: 152 additions & 0 deletions packages/next/src/server/typescript/rules/server-boundary.ts
Original file line number Diff line number Diff line change
@@ -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<T>`.
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
35 changes: 29 additions & 6 deletions packages/next/src/server/typescript/rules/server.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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) {
Expand All @@ -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(),
})
}
}
}
}
}

Expand Down
Loading

0 comments on commit e1480ec

Please sign in to comment.