Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(feature-detector): introduces edge signature detection #226

Merged
merged 7 commits into from
Jan 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 =
Kikobeats marked this conversation as resolved.
Show resolved Hide resolved
'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...
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// I wish we could use node@18's fetch...

not really since we have to support node14 and node16 ☹️

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime, yes. But this is a build-time script. And we don't strictly need to build using node16 & node14. We strictly need to test the code and be sure it's running on node16 & node14.

However, to simplify CI, our test job installs deps, builds and runs tests on nodeX version, while we could always install & build on node 18 (and share this artifact between tests runs...), and use nodeX version for testing only.

I don't think it worth the hassle, so I rented and used builtin http utilities instead of undici.

const content = await new Promise<string>((resolve, reject) =>
get(`${repositoryBaseUrl}/${file}`, (response) => {
let content = ''
response.setEncoding('utf8')
response.on('data', (chunk: string) => (content += chunk))
Kikobeats marked this conversation as resolved.
Show resolved Hide resolved
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: {') {
Kikobeats marked this conversation as resolved.
Show resolved Hide resolved
// 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.
Kikobeats marked this conversation as resolved.
Show resolved Hide resolved
*/
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())
Copy link

@nuta nuta Jan 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: May I assume that getReturnType won't be in an infinite loop? Is the following code properly rejected?

type A = A;                 // invalid: circular type definition

const exported = () => {    // valid: cyclic references
  const x = {}
  x.next = x
  return x.next;
}

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added two test cases. For invalid cyclic types, getReturnType() would return any.

For valid cyclic reference, it depends on the language: any for TS, and { next: any } for JS (interesting!).

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