Skip to content

Commit

Permalink
Add @vercel/static-config (vercel#6897)
Browse files Browse the repository at this point in the history
  • Loading branch information
TooTallNate authored Oct 28, 2021
1 parent b890ac1 commit 9c67e81
Show file tree
Hide file tree
Showing 13 changed files with 339 additions and 1 deletion.
1 change: 1 addition & 0 deletions .eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,4 @@ packages/node/src/bridge.ts
packages/node/test/fixtures
packages/node-bridge/bridge.js
packages/node-bridge/launcher.js
packages/static-config/test/fixtures
1 change: 1 addition & 0 deletions packages/static-config/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
test/fixtures
1 change: 1 addition & 0 deletions packages/static-config/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
/dist
42 changes: 42 additions & 0 deletions packages/static-config/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
{
"name": "@vercel/static-config",
"version": "0.0.0",
"license": "MIT",
"main": "./dist/index",
"repository": {
"type": "git",
"url": "https://github.com/vercel/vercel.git",
"directory": "packages/static-config"
},
"scripts": {
"build": "tsc",
"test-unit": "jest",
"prepublishOnly": "tsc"
},
"files": [
"dist"
],
"dependencies": {
"ajv": "8.6.3",
"json-schema-to-ts": "1.6.4",
"ts-morph": "12.0.0"
},
"devDependencies": {
"@types/jest": "27.0.2",
"@types/node": "*"
},
"jest": {
"preset": "ts-jest",
"globals": {
"ts-jest": {
"diagnostics": false,
"isolatedModules": true
}
},
"verbose": false,
"testEnvironment": "node",
"testMatch": [
"<rootDir>/test/**/*.test.ts"
]
}
}
120 changes: 120 additions & 0 deletions packages/static-config/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import {
Project,
SourceFile,
SyntaxKind,
NodeFlags,
ObjectLiteralExpression,
Node,
ArrayLiteralExpression,
} from 'ts-morph';
import Ajv from 'ajv';
import { FromSchema, JSONSchema } from 'json-schema-to-ts';

const ajv = new Ajv();

export const BaseFunctionConfigSchema = {
type: 'object',
properties: {
runtime: { type: 'string' },
memory: { type: 'number' },
maxDuration: { type: 'number' },
regions: {
type: 'array',
items: { type: 'string' },
},
},
} as const;

export type BaseFunctionConfig = FromSchema<typeof BaseFunctionConfigSchema>;

export function getConfig<
T extends JSONSchema = typeof BaseFunctionConfigSchema
>(project: Project, sourcePath: string, schema?: T): FromSchema<T> | null {
const sourceFile = project.addSourceFileAtPath(sourcePath);
const configNode = getConfigNode(sourceFile);
if (!configNode) return null;
const config = getValue(configNode);
// @ts-ignore
return validate(schema || BaseFunctionConfigSchema, config);
}

function validate<T>(schema: T, data: any): FromSchema<T> {
const isValid = ajv.compile(schema);
if (!isValid(data)) {
// TODO: better error message
throw new Error('Invalid data');
}
return data as FromSchema<T>;
}

function getConfigNode(sourceFile: SourceFile) {
return sourceFile
.getDescendantsOfKind(SyntaxKind.ObjectLiteralExpression)
.find(objectLiteral => {
// Make sure the object is assigned to "config"
const varDec = objectLiteral.getParentIfKind(
SyntaxKind.VariableDeclaration
);
if (varDec?.getName() !== 'config') return false;

// Make sure assigned with `const`
const varDecList = varDec.getParentIfKind(
SyntaxKind.VariableDeclarationList
);
const isConst = (varDecList?.getFlags() ?? 0) & NodeFlags.Const;
if (!isConst) return false;

// Make sure it is exported
const exp = varDecList?.getParentIfKind(SyntaxKind.VariableStatement);
if (!exp?.isExported()) return false;

return true;
});
}

function getValue(valueNode: Node): unknown {
if (Node.isStringLiteral(valueNode)) {
return eval(valueNode.getText());
} else if (Node.isNumericLiteral(valueNode)) {
return Number(valueNode.getText());
} else if (Node.isTrueLiteral(valueNode)) {
return true;
} else if (Node.isFalseLiteral(valueNode)) {
return false;
} else if (Node.isNullLiteral(valueNode)) {
return null;
} else if (Node.isArrayLiteralExpression(valueNode)) {
return getArray(valueNode);
} else if (Node.isObjectLiteralExpression(valueNode)) {
return getObject(valueNode);
} else if (
Node.isIdentifier(valueNode) &&
valueNode.getText() === 'undefined'
) {
return undefined;
}
throw new Error(
`Unhandled type: "${valueNode.getKindName()}" ${valueNode.getText()}`
);
}

function getObject(obj: ObjectLiteralExpression): unknown {
const rtn: { [v: string]: unknown } = {};
for (const prop of obj.getProperties()) {
if (!Node.isPropertyAssignment(prop)) continue;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const [nameNode, _colon, valueNode] = prop.getChildren();
const name = nameNode.getText();
rtn[name] = getValue(valueNode);
}
return rtn;
}

function getArray(arr: ArrayLiteralExpression): unknown {
const elementNodes = arr.getElements();
const rtn = new Array(elementNodes.length);
for (let i = 0; i < elementNodes.length; i++) {
rtn[i] = getValue(elementNodes[i]);
}
return rtn;
}
11 changes: 11 additions & 0 deletions packages/static-config/test/fixtures/deno.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import ms from 'https://denopkg.com/TooTallNate/ms';
import { readerFromStreamReader } from 'https://deno.land/std@0.107.0/io/streams.ts';

export const config = {
runtime: 'deno',
location: 'https://example.com/page',
};

export default async ({ request }: Deno.RequestEvent) => {
return new Response('Hello from Deno');
};
3 changes: 3 additions & 0 deletions packages/static-config/test/fixtures/invalid-schema.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const config = {
runtime: 0,
};
1 change: 1 addition & 0 deletions packages/static-config/test/fixtures/no-config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const foo = 'bar';
10 changes: 10 additions & 0 deletions packages/static-config/test/fixtures/node.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import fs from 'fs';

export const config = {
runtime: 'node',
memory: 1024,
};

export default function (req, res) {
res.end('Hi from Node');
}
53 changes: 53 additions & 0 deletions packages/static-config/test/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { join } from 'path';
import { Project } from 'ts-morph';
import { getConfig } from '../src';

describe('getConfig()', () => {
it('should parse config from Node.js file', () => {
const project = new Project();
const sourcePath = join(__dirname, 'fixtures/node.js');
const config = getConfig(project, sourcePath);
expect(config).toMatchInlineSnapshot(`
Object {
"memory": 1024,
"runtime": "node",
}
`);
});

it('should parse config from Deno file', () => {
const project = new Project();
const sourcePath = join(__dirname, 'fixtures/deno.ts');
const config = getConfig(project, sourcePath, {
type: 'object',
properties: {
location: { type: 'string' },
},
} as const);
expect(config).toMatchInlineSnapshot(`
Object {
"location": "https://example.com/page",
"runtime": "deno",
}
`);
});

it('should return `null` when no config was exported', () => {
const project = new Project();
const sourcePath = join(__dirname, 'fixtures/no-config.js');
const config = getConfig(project, sourcePath);
expect(config).toBeNull();
});

it('should throw an error upon schema validation failure', () => {
const project = new Project();
const sourcePath = join(__dirname, 'fixtures/invalid-schema.js');
let err;
try {
getConfig(project, sourcePath);
} catch (_err) {
err = _err;
}
expect(err.message).toEqual('Invalid data');
});
});
4 changes: 4 additions & 0 deletions packages/static-config/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{
"extends": "../tsconfig.json",
"include": ["*.test.ts"]
}
16 changes: 16 additions & 0 deletions packages/static-config/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"compilerOptions": {
"strict": true,
"esModuleInterop": true,
"lib": ["esnext"],
"target": "es2018",
"module": "commonjs",
"outDir": "dist",
"sourceMap": true,
"declaration": true,
"moduleResolution": "node",
"typeRoots": ["./@types", "./node_modules/@types"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}
Loading

0 comments on commit 9c67e81

Please sign in to comment.