Skip to content

Commit

Permalink
feat(feature-detector): introduces edge signature detection (vercel#226)
Browse files Browse the repository at this point in the history
  • Loading branch information
feugy authored and jridgewell committed Jun 23, 2023
1 parent c184a6e commit f34bf7a
Show file tree
Hide file tree
Showing 22 changed files with 19,101 additions and 19 deletions.
5 changes: 5 additions & 0 deletions .changeset/kind-falcons-jump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@edge-runtime/feature-detector': patch
---

introduce hasEdgeSignature detection utility
1 change: 1 addition & 0 deletions docs/pages/packages/_meta.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{
"cookies": "@edge-runtime/cookies",
"feature-detector": "@edge-runtime/feature-detector",
"format": "@edge-runtime/format",
"jest-environment": "@edge-runtime/jest-environment",
"jest-expect": "@edge-runtime/jest-expect",
Expand Down
50 changes: 50 additions & 0 deletions docs/pages/packages/feature-detector.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { Callout } from 'nextra-theme-docs'
import { Tabs, Tab } from '../../components/tabs'

# Edge Runtime Code analysis utilities

The **@edge-runtime/feature-detector** package contains utilities to analyze code running on the edge and its used low-level APIs.

It leverages the excellent [ts-morph](https://ts-morph.com/) package, which is a wrapper around TypeScript compiler API to navigate its Abstract Syntaxt Tree (AST).

It can analyse JavaScript and TypeScript code.

<Callout type='warning' emoji='⚠️'>
Please note this is an alpha version.
</Callout>

## Installation

<Tabs items={['npm', 'yarn', 'pnpm']} storageKey='selected-pkg-manager'>
<Tab>```sh npm install @edge-runtime/feature-detector --save ```</Tab>
<Tab>```sh yarn add @edge-runtime/feature-detector ```</Tab>
<Tab>```sh pnpm install @edge-runtime/feature-detector --save ```</Tab>
</Tabs>

This package includes built-in TypeScript support.

## Usage

Here is an example of checking whether a file's default export is a function matching the edge signature, such as:

```js
export default function () {
return Response.json({ message: 'hello world!' })
}
```

```ts
import { hasEdgeSignature } from '@edge-runtime/feature-detector'

const sourceFilePath = './test.js' // could be TypeScript as well. Must be in current working directory

if (hasEdgeSignature(sourceFilePath)) {
console.log(`${sourcefilePath} can run on the edge``)
}
```

## API

<Callout type='warning' emoji='⚠️'>
Work in progress
</Callout>
38 changes: 38 additions & 0 deletions packages/feature-detector/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<div align="center">
<br>
<img src="https://edge-runtime.vercel.app/og-image.png" alt="edge-runtime logo">
<br>
<br>
<p align="center"><strong>@edge-runtime/feature-detector</strong>: utilities to analyze code running on the edge and its used low-level APIs.</p>
<p align="center">See <a href="https://edge-runtime.vercel.app/packages/feature-detector" target='_blank' rel='noopener noreferrer'>@edge-runtime/feature-detector</a> section in our <a href="https://edge-runtime.vercel.app/" target='_blank' rel='noopener noreferrer'>website</a> for more information.</p>
<br>
</div>

## Install

**Note: this is an alpha version.**

Using npm:

```sh
npm install @edge-runtime/feature-detector --save
```

or using yarn:

```sh
yarn add @edge-runtime/feature-detector --dev
```

or using pnpm:

```sh
pnpm install @edge-runtime/feature-detector --save
```

## License

**@edge-runtime/feature-detector** © [Vercel](https://vercel.com), released under the [MPLv2](https://github.com/vercel/edge-runtime/blob/main/LICENSE.md) License.<br>
Authored and maintained by [Vercel](https://vercel.com) with help from [contributors](https://github.com/vercel/edge-runtime/contributors).

> [vercel.com](https://vercel.com) · GitHub [Vercel](https://github.com/vercel) · Twitter [@vercel](https://twitter.com/vercel)
6 changes: 6 additions & 0 deletions packages/feature-detector/jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import buildConfig from '../../jest.config'
import type { Config } from '@jest/types'

const config: Config.InitialOptions = buildConfig(__dirname)
config.transform!['\\.txt$'] = 'jest-text-transformer'
export default config
40 changes: 40 additions & 0 deletions packages/feature-detector/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"name": "@edge-runtime/feature-detector",
"description": "Helpers for analyzing code running on the edge and its used low-level APIs",
"homepage": "https://edge-runtime.vercel.app/packages/feature-detector",
"version": "1.0.0-alpha.1",
"main": "dist/index.js",
"module": "dist/index.mjs",
"repository": {
"directory": "packages/feature-detector",
"type": "git",
"url": "git+https://github.com/vercel/edge-runtime.git"
},
"bugs": {
"url": "https://github.com/vercel/edge-runtime/issues"
},
"keywords": [],
"devDependencies": {
"jest-text-transformer": "1.0.4",
"next": "^13.1.1",
"react": "~18.2.0",
"react-dom": "~18.2.0",
"tsup": "^6"
},
"scripts": {
"build": "pnpm run build:patch && pnpm run build:src",
"build:patch": "ts-node scripts/patch-type-definition.ts",
"build:src": "tsup",
"clean:build": "rm -rf dist",
"prebuild": "pnpm run clean:build",
"test": "jest"
},
"license": "MPLv2",
"publishConfig": {
"access": "public"
},
"types": "dist/index.d.ts",
"dependencies": {
"ts-morph": "^17.0.1"
}
}
104 changes: 104 additions & 0 deletions packages/feature-detector/scripts/patch-type-definition.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// assumes being ran in packages/feature-detector

import { get } from 'node:https'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'

const repositoryBaseUrl =
'https://raw.githubusercontent.com/microsoft/TypeScript/main/lib'
const destinationFile = join('src', 'utils/type-definition.txt')

async function writeTypeDefinition() {
const aggregatedContent = await fetchTypeDefinitions([
'lib.es2022.d.ts',
'lib.dom.d.ts',
])
await writeFile(destinationFile, patchContent(aggregatedContent).join('\n'))
}

async function fetchTypeDefinitions(files: string[]) {
const result = []
for (const file of files) {
result.push(...(await fetchTypeDefinition(file)))
}
return result
}

async function fetchTypeDefinition(file: string): Promise<string[]> {
// I wish we could use node@18's fetch...
const content = await new Promise<string>((resolve, reject) =>
get(`${repositoryBaseUrl}/${file}`, (response) => {
let content = ''
response.setEncoding('utf8')
response.on('data', (chunk: string) => (content += chunk))
response.once('end', () => resolve(content))
response.once('error', reject)
})
)
const result = []
for (const line of content.split('\n')) {
const reference = hasReference(line)
if (reference) {
result.push(...(await fetchTypeDefinition(`lib.${reference}.d.ts`)))
} else {
result.push(line)
}
}
return result
}

function hasReference(line: string) {
const match = line.match(/^\/\/\/\s*<reference\s+lib\s*=\s*"(.+)"\s*\/>/)
return match ? match[1] : null
}

function patchContent(lines: string[]) {
const result = []
let inComment = false
for (const line of lines) {
if (inComment) {
if (isMultiCommentEnd(line)) {
inComment = false
}
} else {
if (isMultiCommentStart(line)) {
inComment = true
} else if (!isSingleComment(line) && !isBlankLine(line)) {
result.push(...applyPatch(line))
}
}
}
return result
}

function applyPatch(line: string) {
if (line === 'declare var Response: {') {
// adding missing json static method
// https://fetch.spec.whatwg.org/#response-class
return [line, ' json(data?: any, init?: ResponseInit): Response;']
}
return [line]
}

function isMultiCommentStart(line: string) {
return /^\s*\/\*.*(?<!\*\/\s*)$/.test(line)
}

function isMultiCommentEnd(line: string) {
return /\*\/\s*$/.test(line)
}

function isSingleComment(line: string) {
return /^(?!\s*\/\/\/ <r)\s*\/\/|^\s*\/\*.+\*\//.test(line)
}

function isBlankLine(line: string) {
return /^\s*$/.test(line)
}

writeTypeDefinition()
.then(() => process.exit(0))
.catch((error) => {
console.log('Errored', error)
process.exit(1)
})
24 changes: 24 additions & 0 deletions packages/feature-detector/src/has-edge-signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { Project } from 'ts-morph'
import { getDefaultExport } from './utils/exports'
import { getReturnType } from './utils/functions'
import { buildProject } from './utils/project'
import { extractFromPromise, isSubClassOf } from './utils/types'

/**
* Returns true if the default export of the provided file is a function returning a web Response object.
*/
export function hasEdgeSignature(
sourcePath: string,
project: Project = buildProject()
) {
const sourceFile = project.addSourceFileAtPath(sourcePath)
const defaultExport = getDefaultExport(sourceFile)
if (!defaultExport) {
return false
}
const returnType = getReturnType(defaultExport)
if (!returnType) {
return false
}
return isSubClassOf(extractFromPromise(returnType), 'Response')
}
1 change: 1 addition & 0 deletions packages/feature-detector/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './has-edge-signature'
15 changes: 15 additions & 0 deletions packages/feature-detector/src/utils/exports.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Node, SourceFile } from 'ts-morph'

/**
* Get the default export of a modules, if any.
* Supported:
* - export definition: `export default function foo() {}`
* - exported declaration: `const foo = 'bar'; export default foo`
*/
export function getDefaultExport(sourceFile: SourceFile): Node | undefined {
const defaultExport = sourceFile.getDefaultExportSymbol()
return (
defaultExport?.getValueDeclaration() ??
defaultExport?.getDeclarations()?.[0] // when exporting a variable: `export default handler`
)
}
25 changes: 25 additions & 0 deletions packages/feature-detector/src/utils/functions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { Node, SyntaxKind, Type } from 'ts-morph'

const { ExportAssignment, Identifier, FunctionDeclaration, ArrowFunction } =
SyntaxKind

/**
* Exteracts the return type of a function, if any.
* Supports:
* - function declaration `function foo() {}`
* - function expression `const foo = function() {}`
* - arrow functions `const foo = () => {}`
* - module exports
*/
export function getReturnType(node?: Node): Type | undefined {
switch (node?.getKind()) {
case ExportAssignment:
return getReturnType(node.asKind(ExportAssignment)?.getExpression())
case Identifier:
return getReturnType(node.getSymbol()?.getValueDeclaration())
case FunctionDeclaration:
return node.asKind(FunctionDeclaration)?.getReturnType()
case ArrowFunction:
return node.asKind(ArrowFunction)?.getReturnType()
}
}
17 changes: 17 additions & 0 deletions packages/feature-detector/src/utils/project.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { Project } from 'ts-morph'
// @ts-ignore will be resolved by tsup & jest
import typeDefinitionContent from './type-definition.txt'

/**
* Builds a TS-morph project that allows JS code and loads our custom Response + Promise types
*/
export function buildProject() {
const project = new Project({
compilerOptions: {
allowJs: true,
},
})
project.createSourceFile('node_modules/index.d.ts', typeDefinitionContent)
project.addDirectoryAtPathIfExists('.')
return project
}
Loading

0 comments on commit f34bf7a

Please sign in to comment.