Skip to content

Commit

Permalink
feat(start-vite-plugin): copy the TanStack Start specific plugin logi…
Browse files Browse the repository at this point in the history
…c into its own standalone vite plugin (#1768)

* copy the router-vite-plugin into start-vite-plugin

* test: remove the tests we dont need

* export config-name from generator

* undo that last one

* reduce this down

* eliminate the router logic

* undo the touches to the generator and cli

* move this down here

* eslint

* chore: remove deps that are not needed

* test: test for createServerFn being renamed during the destructured import

* fix: handle destructured rename case

* test: handle star imports

* chore: push

* chore: remove this
  • Loading branch information
SeanCassiere authored Jun 18, 2024
1 parent 92c6c2b commit 1f35bcb
Show file tree
Hide file tree
Showing 17 changed files with 732 additions and 0 deletions.
8 changes: 8 additions & 0 deletions packages/start-vite-plugin/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
// @ts-check

/** @type {import('eslint').Linter.Config} */
const config = {
ignorePatterns: ['test-files', 'snapshots', 'tests'],
}

module.exports = config
5 changes: 5 additions & 0 deletions packages/start-vite-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
<img src="https://static.scarf.sh/a.png?x-pxid=d988eb79-b0fc-4a2b-8514-6a1ab932d188" />

# TanStack Start Vite Plugin

See https://tanstack.com/router/latest/docs/framework/react/guide/file-based-routing
75 changes: 75 additions & 0 deletions packages/start-vite-plugin/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
{
"name": "@tanstack/start-vite-plugin",
"version": "1.38.0",
"description": "",
"author": "Tanner Linsley",
"license": "MIT",
"repository": {
"type": "git",
"url": "https://github.com/TanStack/router.git",
"directory": "packages/start-vite-plugin"
},
"homepage": "https://tanstack.com/router",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"type": "module",
"types": "dist/esm/index.d.ts",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.js",
"exports": {
".": {
"import": {
"types": "./dist/esm/index.d.ts",
"default": "./dist/esm/index.js"
},
"require": {
"types": "./dist/cjs/index.d.cts",
"default": "./dist/cjs/index.cjs"
}
},
"./package.json": "./package.json"
},
"sideEffects": false,
"scripts": {
"clean": "rimraf ./dist && rimraf ./coverage",
"test": "pnpm test:eslint && pnpm test:types && pnpm test:build && pnpm test:unit",
"test:unit": "vitest --typecheck --watch=false",
"test:eslint": "eslint --ext .ts,.tsx ./src",
"test:types": "tsc --noEmit",
"test:build": "publint --strict",
"build": "vite build"
},
"keywords": [
"react",
"location",
"router",
"routing",
"async",
"async router",
"typescript"
],
"engines": {
"node": ">=12"
},
"files": [
"dist",
"src/**"
],
"dependencies": {
"@babel/core": "^7.23.7",
"@babel/generator": "^7.23.6",
"@babel/plugin-syntax-jsx": "^7.24.1",
"@babel/plugin-syntax-typescript": "^7.24.1",
"@babel/plugin-transform-react-jsx": "^7.23.4",
"@babel/plugin-transform-typescript": "^7.24.1",
"@babel/template": "^7.24.0",
"@babel/traverse": "^7.24.1",
"@babel/types": "^7.24.0",
"@types/babel__core": "^7.20.5",
"@types/babel__generator": "^7.6.8",
"@types/babel__template": "^7.4.4",
"@types/babel__traverse": "^7.20.5"
}
}
49 changes: 49 additions & 0 deletions packages/start-vite-plugin/src/ast.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import * as babel from '@babel/core'

export type CompileAstFn = (compileOpts: {
code: string
filename: string
getBabelConfig: () => { plugins: Array<any> }
}) => Promise<{
code: string
map: any
}>

export function compileAst(makeOpts: { root: string }) {
return async (opts: {
code: string
filename: string
getBabelConfig: () => { plugins: Array<any> }
}): Promise<{
code: string
map: any
}> => {
const res = babel.transformSync(opts.code, {
plugins: [
['@babel/plugin-syntax-jsx', {}],
[
'@babel/plugin-syntax-typescript',
{
isTSX: true,
},
],
...opts.getBabelConfig().plugins,
],
root: makeOpts.root,
filename: opts.filename,
sourceMaps: true,
})

if (res?.code) {
return {
code: res.code,
map: res.map,
}
}

return {
code: opts.code,
map: null,
}
}
}
163 changes: 163 additions & 0 deletions packages/start-vite-plugin/src/compilers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import * as t from '@babel/types'

import type * as babel from '@babel/core'
import type { CompileAstFn } from './ast'

export async function createServerFnCompiler(opts: {
code: string
compile: CompileAstFn
filename: string
}) {
return await opts.compile({
code: opts.code,
filename: opts.filename,
getBabelConfig: () => ({
plugins: [
[
{
visitor: {
Program: {
enter(programPath: babel.NodePath<t.Program>) {
let identifierType:
| 'ImportSpecifier'
| 'ImportNamespaceSpecifier' = 'ImportSpecifier'

let namespaceId = ''
let serverFnId = 'createServerFn'

programPath.traverse({
ImportDeclaration: (path) => {
if (path.node.source.value !== '@tanstack/start') {
return
}

path.node.specifiers.forEach((specifier) => {
// handles a destructured import being renamed like "import { createServerFn as myCreateServerFn } from '@tanstack/start';"
if (
specifier.type === 'ImportSpecifier' &&
specifier.imported.type === 'Identifier'
) {
if (specifier.imported.name === 'createServerFn') {
serverFnId = specifier.local.name
identifierType = 'ImportSpecifier'
}
}

// handles a namespace import like "import * as TanStackStart from '@tanstack/start';"
if (specifier.type === 'ImportNamespaceSpecifier') {
identifierType = 'ImportNamespaceSpecifier'
namespaceId = specifier.local.name
serverFnId = `${specifier.local.name}.createServerFn`
}
})
},
CallExpression: (path) => {
const importSpecifierCondition =
path.node.callee.type === 'Identifier' &&
path.node.callee.name === serverFnId

const importNamespaceSpecifierCondition =
path.node.callee.type === 'MemberExpression' &&
path.node.callee.property.type === 'Identifier' &&
path.node.callee.property.name === 'createServerFn'

const createServerFnEntryCondition =
identifierType === 'ImportSpecifier'
? importSpecifierCondition
: importNamespaceSpecifierCondition

if (createServerFnEntryCondition) {
// If the function at createServerFn(_, MyFunc) doesn't have a
// 'use server' directive at the top of the function scope,
// then add it.
const fn = path.node.arguments[1]

if (
t.isFunctionExpression(fn) ||
t.isArrowFunctionExpression(fn)
) {
if (t.isBlockStatement(fn.body)) {
const hasUseServerDirective =
fn.body.directives.some((directive) => {
return directive.value.value === 'use server'
})

if (!hasUseServerDirective) {
fn.body.directives.unshift(
t.directive(t.directiveLiteral('use server')),
)
}
}
} else if (
t.isIdentifier(fn) ||
t.isCallExpression(fn)
) {
// A function was passed to createServerFn in the form of an
// identifier or a call expression that returns a function.

// We wrap the identifier/call expression in a function
// expression that accepts the same arguments as the original
// function with the "use server" directive at the top of the
// function scope.

const args = t.restElement(t.identifier('args'))

// Annotate args with the type:
// Parameters<Parameters<typeof createServerFn>[1]>

args.typeAnnotation = t.tsTypeAnnotation(
t.tsTypeReference(
t.identifier('Parameters'),
t.tsTypeParameterInstantiation([
t.tsIndexedAccessType(
t.tsTypeReference(
t.identifier('Parameters'),
t.tsTypeParameterInstantiation([
t.tsTypeQuery(t.identifier(serverFnId)),
]),
),
t.tsLiteralType(t.numericLiteral(1)),
),
]),
),
)

const wrappedFn = t.arrowFunctionExpression(
[args],
t.blockStatement(
[
t.returnStatement(
t.callExpression(
t.memberExpression(
fn,
t.identifier('apply'),
),
[
t.identifier('this'),
t.identifier('args'),
],
),
),
],
[t.directive(t.directiveLiteral('use server'))],
),
)

path.node.arguments[1] = wrappedFn
}
}
},
})
},
},
},
},
{
root: process.cwd(),
minify: process.env.NODE_ENV === 'production',
},
],
].filter(Boolean),
}),
})
}
72 changes: 72 additions & 0 deletions packages/start-vite-plugin/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { fileURLToPath, pathToFileURL } from 'node:url'
import { compileAst } from './ast'
import { createServerFnCompiler } from './compilers'
import type { Plugin } from 'vite'

const debug = Boolean(process.env.TSR_VITE_DEBUG)

export function TanStackStartVite(): Array<Plugin> {
return [TanStackStartViteCreateServerFn()]
}

export function TanStackStartViteCreateServerFn(): Plugin {
let ROOT: string = process.cwd()

return {
name: 'vite-plugin-tanstack-start-create-server-fn',
enforce: 'pre',
configResolved: async (config) => {
ROOT = config.root
},
async transform(code, id) {
const url = pathToFileURL(id)
url.searchParams.delete('v')
id = fileURLToPath(url).replace(/\\/g, '/')

const compile = compileAst({
root: ROOT,
})

if (code.includes('createServerFn')) {
if (code.includes('@react-refresh')) {
throw new Error(
`We detected that the '@vitejs/plugin-react' was passed before '@tanstack/start-vite-plugin'. Please make sure that '@tanstack/router-vite-plugin' is passed before '@vitejs/plugin-react' and try again:
e.g.
plugins: [
TanStackStartVite(), // Place this before viteReact()
viteReact(),
]
`,
)
}

if (debug) console.info('Handling createServerFn for id: ', id)
const compiled = await createServerFnCompiler({
code,
compile,
filename: id,
})

if (debug) console.info('')
if (debug) console.info('Compiled Output')
if (debug) console.info('')
if (debug) console.info(compiled.code)
if (debug) console.info('')
if (debug) console.info('')
if (debug) console.info('')
if (debug) console.info('')
if (debug) console.info('')
if (debug) console.info('')
if (debug) console.info('')
if (debug) console.info('')
if (debug) console.info('')
if (debug) console.info('')

return compiled
}

return null
},
}
}
Loading

0 comments on commit 1f35bcb

Please sign in to comment.