Skip to content

Commit

Permalink
feat: filename.server$.ext
Browse files Browse the repository at this point in the history
  • Loading branch information
tannerlinsley committed Feb 28, 2023
1 parent 647fea1 commit 747b906
Show file tree
Hide file tree
Showing 8 changed files with 168 additions and 71 deletions.
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,25 @@ A function that can be called isomorphically from server or client side code to
- The request object to be passed to the `fetch` call to the server function.
- Can be used to add headers, signals, etc.

## Server-Only Files (`filename.server$.ext`)

The `filename.server$.ext` pattern can be used to create server-side only files. These files will be removed from the client bundle. This is useful for things like server-side only imports, or server-side only code. It works with any file name and extension so long as `.server$.` is found in the resolved file pathname.

When a server-only file is imported on the client, it will be provided the same exports, but stubbed with undefined values. Don't put anything sensitive in the exported variable name! 😜

```tsx
// secret.server$.ts`
export const secret = 'This is top secret!'
export const anotherSecret = '🤫 Shhh!'
```

Client output:

```tsx
export const secret = undefined
export const anotherSecret = undefined
```

## `server$` (Coming Soon)

The `server$` function can be used to scope any expression to the server-bundle only. This means that the expression will be removed from the client bundle. This is useful for things like server-side only imports, or server-side only code.
Expand Down
3 changes: 3 additions & 0 deletions examples/astro-basic/src/app/root.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { serverFn$ } from '@tanstack/bling'
import { secret } from './secret.server$'

const sayHello = serverFn$(() => console.log('Hello world'))

export function App() {
console.log('Do you know the secret?', secret)

return (
<html>
<head>
Expand Down
1 change: 1 addition & 0 deletions examples/astro-basic/src/app/secret.server$.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const secret = 'This is a secret!'
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"repository": "https://github.com/tanstack/bling.git",
"scripts": {
"build": "ts-node scripts/build.ts",
"dev": "pnpm -rc --parallel exec 'pnpm dev'",
"dev": "pnpm -rc --filter \"./packages/**\" --parallel exec 'pnpm dev'",
"test": "exit 0",
"test:dev": "exit 0",
"test:ci": "exit 0",
Expand Down
2 changes: 1 addition & 1 deletion packages/bling/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@
},
"devDependencies": {
"concurrently": "^7.6.0",
"esbuild": "^0.16.3",
"esbuild": "^0.16.17",
"esbuild-plugin-replace": "^1.3.0",
"typescript": "4.9.4",
"vitest": "^0.26.2"
Expand Down
162 changes: 116 additions & 46 deletions packages/bling/src/babel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,60 +6,77 @@

import crypto from 'crypto'
import nodePath from 'path'
import * as esbuild from 'esbuild'

const INLINE_SERVER_ROUTE_PREFIX = '/_m'

function transformServer({ types: t, template }) {
function getIdentifier(path) {
const parentPath = path.parentPath
if (parentPath.type === 'VariableDeclarator') {
const pp = parentPath
const name = pp.get('id')
return name.node.type === 'Identifier' ? name : null
}
if (parentPath.type === 'AssignmentExpression') {
const pp = parentPath
const name = pp.get('left')
return name.node.type === 'Identifier' ? name : null
}
if (path.node.type === 'ArrowFunctionExpression') {
return null
}
return path.node.id && path.node.id.type === 'Identifier'
? path.get('id')
: null
}
function isIdentifierReferenced(ident) {
const b = ident.scope.getBinding(ident.node.name)
if (b && b.referenced) {
if (b.path.type === 'FunctionDeclaration') {
return !b.constantViolations
.concat(b.referencePaths)
.every((ref) => ref.findParent((p) => p === b.path))
}
return true
export function compileServerFile$({ code }) {
let compiled = esbuild.buildSync({
stdin: {
contents: code,
},
write: false,
metafile: true,
platform: 'neutral',
format: 'esm',
// loader: {
// '.js': 'jsx',
// },
logLevel: 'silent',
})

let exps

for (let key in compiled.metafile.outputs) {
if (compiled.metafile.outputs[key].entryPoint) {
exps = compiled.metafile.outputs[key].exports
}
return false
}
function markFunction(path, state) {
const ident = getIdentifier(path)
if (ident && ident.node && isIdentifierReferenced(ident)) {
state.refs.add(ident)
}

if (!exps) {
throw new Error('Could not find entry point to detect exports')
}
function markImport(path, state) {
const local = path.get('local')
// if (isIdentifierReferenced(local)) {
state.refs.add(local)
// }

console.log(exps)

compiled = esbuild.buildSync({
stdin: {
contents: `${exps
.map((key) => `export const ${key} = undefined`)
.join('\n')}`,
},
write: false,
platform: 'neutral',
format: 'esm',
})

console.log(compiled.outputFiles[0].text)

return {
code: compiled.outputFiles[0].text,
}
}

export function compileServerFn$({ code, compiler, id, ssr }) {
const compiledCode = compiler(code, id, (source: any, id: any) => ({
plugins: [
[
transformServerFn$,
{
ssr,
root: process.cwd(),
minify: process.env.NODE_ENV === 'production',
},
],
].filter(Boolean),
}))

function hashFn(str) {
return crypto
.createHash('shake256', { outputLength: 5 /* bytes = 10 hex digits*/ })
.update(str)
.digest('hex')
return {
code: compiledCode,
}
}

export function transformServerFn$({ types: t, template }) {
return {
visitor: {
Program: {
Expand Down Expand Up @@ -366,4 +383,57 @@ function transformServer({ types: t, template }) {
},
}
}
export { transformServer as default }

function getIdentifier(path) {
const parentPath = path.parentPath
if (parentPath.type === 'VariableDeclarator') {
const pp = parentPath
const name = pp.get('id')
return name.node.type === 'Identifier' ? name : null
}
if (parentPath.type === 'AssignmentExpression') {
const pp = parentPath
const name = pp.get('left')
return name.node.type === 'Identifier' ? name : null
}
if (path.node.type === 'ArrowFunctionExpression') {
return null
}
return path.node.id && path.node.id.type === 'Identifier'
? path.get('id')
: null
}

function isIdentifierReferenced(ident) {
const b = ident.scope.getBinding(ident.node.name)
if (b && b.referenced) {
if (b.path.type === 'FunctionDeclaration') {
return !b.constantViolations
.concat(b.referencePaths)
.every((ref) => ref.findParent((p) => p === b.path))
}
return true
}
return false
}

function markFunction(path, state) {
const ident = getIdentifier(path)
if (ident && ident.node && isIdentifierReferenced(ident)) {
state.refs.add(ident)
}
}

function markImport(path, state) {
const local = path.get('local')
// if (isIdentifierReferenced(local)) {
state.refs.add(local)
// }
}

function hashFn(str) {
return crypto
.createHash('shake256', { outputLength: 5 /* bytes = 10 hex digits*/ })
.update(str)
.digest('hex')
}
48 changes: 26 additions & 22 deletions packages/bling/src/vite.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { Plugin } from 'vite'
import viteReact, { Options } from '@vitejs/plugin-react'
import { fileURLToPath, pathToFileURL } from 'url'
import babel from './babel'
import { compileServerFile$, compileServerFn$ } from './babel'

export function bling(opts?: { babel?: Options['babel'] }): Plugin {
const options = opts ?? {}
Expand All @@ -16,25 +16,31 @@ export function bling(opts?: { babel?: Options['babel'] }): Plugin {
? void 0
: transformOptions.ssr

let ssr = process.env.TEST_ENV === 'client' ? false : isSsr

const url = pathToFileURL(id)
url.searchParams.delete('v')
id = fileURLToPath(url).replace(/\\/g, '/')

const babelOptions =
(fn: any) =>
(...args: any[]) => {
(fn?: (source: any, id: any) => { plugins: any[] }) =>
(source: any, id: any) => {
const b: any =
typeof options.babel === 'function'
? // @ts-ignore
options.babel(...args)
: options.babel ?? { plugins: [] }
const d = fn(...args)
const d = fn?.(source, id)
return {
plugins: [...b.plugins, ...d.plugins],
plugins: [...b.plugins, ...(d?.plugins ?? [])],
}
}

let compiler = (code: string, id: string, fn: any) => {
let compiler = (
code: string,
id: string,
fn?: (source: any, id: any) => { plugins: any[] }
) => {
let plugin = viteReact({
...(options ?? {}),
fastRefresh: false,
Expand All @@ -45,25 +51,23 @@ export function bling(opts?: { babel?: Options['babel'] }): Plugin {
return plugin[0].transform(code, id, transformOptions)
}

let ssr = process.env.TEST_ENV === 'client' ? false : isSsr
if (url.pathname.includes('.server$.') && !ssr) {
const compiled = compileServerFile$({
code,
})

return compiled.code
}

if (code.includes('serverFn$(')) {
return compiler(
const compiled = compileServerFn$({
code,
id.replace(/\.ts$/, '.tsx').replace(/\.js$/, '.jsx'),
(source: any, id: any) => ({
plugins: [
[
babel,
{
ssr,
root: process.cwd(),
minify: process.env.NODE_ENV === 'production',
},
],
].filter(Boolean),
})
)
compiler,
ssr,
id: id.replace(/\.ts$/, '.tsx').replace(/\.js$/, '.jsx'),
})

return compiled.code
}
},
}
Expand Down
2 changes: 1 addition & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 747b906

Please sign in to comment.