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

Middleware support to allow arbitrary ts.CompilerHost changes #96

Open
wants to merge 3 commits into
base: master
Choose a base branch
from
Open
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
45 changes: 44 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export interface PluginConfig {
/**
* Plugin entry point format type, default is program
*/
type?: 'program' | 'config' | 'checker' | 'raw' | 'compilerOptions';
type?: 'program' | 'config' | 'checker' | 'raw' | 'compilerOptions' | 'middleware';

/**
* Should transformer applied after all ones
Expand Down Expand Up @@ -155,6 +155,49 @@ Plugin config entry: `{ "transform": "transformer-module", type: "compilerOption
},
}
```
#### middleware
This type is special — it allows to intercept the build process before it starts, by hooking into `ts.createProgram`, in style similar to Express middlewares.

This mechanism allows to implement virtual modules, virtual file system, custom loaders etc.

Example middleware:

```ts
import * as ts from 'typescript';

export default function(config: { foo: string, bar?: string }): ts.Middleware {
return {
createProgram(opts, next) {
// somehow change options - for example, intercept methods of CompilerHost
opts.host = opts.host ? opts.host : ts.createCompilerHost(opts.options);

// pass control either to real ts.createProgram, or to the earlier middleware
const program = next(opts);

// it's possible to call `next` multiple times, or not call it at all

// when `next()` is called without arguments, it will use the same `opts` object as current middleware

// do something with built program
console.log(program.getTypeCount());

return program;
}
}
}
```

Example configuration:

```json
{
"compilerOptions": {
"plugins": [
{ "transform": "some-middleware-module", "foo": "this is config property", "bar": "this is too" }
]
},
}
```

### Command line

Expand Down
47 changes: 47 additions & 0 deletions packages/ttypescript/__tests__/middlewares/create-program.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import * as ts from 'typescript'

interface TestConfig {
trace: (message: string) => void;
}

export function foo(config: TestConfig): ts.Middleware {
return {
createProgram(opts, next) {
config.trace("foo: before");

opts.host = opts.host ? opts.host : ts.createCompilerHost(opts.options);
const host = opts.host;

const originFileExists = host.fileExists.bind(host);

let existsCalled = false;
host.fileExists = (fileName: string): boolean => {
if (!existsCalled) {
existsCalled = true;
config.trace("in hook");
}
return originFileExists(fileName);
}

const result = next(opts);

config.trace("foo: after");

return result;
}
}
}

export function bar(config: TestConfig): ts.Middleware {
return {
createProgram(opts, next) {
config.trace("bar: before");

const result = next(opts);

config.trace("bar: after");

return result;
}
}
}
24 changes: 24 additions & 0 deletions packages/ttypescript/__tests__/typescript.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,4 +97,28 @@ console.log(abc.toString());
const result = `var x = 1;\n`;
expect(res.outputText).toEqual(result);
});

it('should run middlewares in order', () => {
const lines: string[] = [];
const trace = (m: string) => {
lines.push(m);
}

const res = ts.transpileModule('import { itWorks } from "##arbitrary##";', {
compilerOptions: {
plugins: [
{ transform: __dirname + '/middlewares/create-program.ts', import: 'foo', type: 'middleware', trace },
{ transform: __dirname + '/middlewares/create-program.ts', import: 'bar', type: 'middleware', trace },
] as any,
},
});

expect(lines).toEqual([
"bar: before",
"foo: before",
"in hook",
"foo: after",
"bar: after",
]);
});
});
89 changes: 82 additions & 7 deletions packages/ttypescript/src/PluginCreator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export interface PluginConfig {
/**
* Plugin entry point format type, default is program
*/
type?: 'ls' | 'program' | 'config' | 'checker' | 'raw' | 'compilerOptions';
type?: 'ls' | 'program' | 'config' | 'checker' | 'raw' | 'compilerOptions' | 'middleware';

/**
* Should transformer applied after all ones
Expand All @@ -34,7 +34,24 @@ export interface PluginConfig {
afterDeclarations?: boolean;
}

export interface TransformerBasePlugin {
export type CreateProgramMiddlewareNext = (createProgramOptions?: ts.CreateProgramOptions) => ts.Program;
export type CreateProgramMiddlewareHead = (createProgramOptions: ts.CreateProgramOptions) => ts.Program;
export type CreateProgramMiddleware = (createProgramOptions: ts.CreateProgramOptions, next: CreateProgramMiddlewareNext) => ts.Program;

declare module 'typescript' {
export interface Middleware {
createProgram?: CreateProgramMiddleware;
Copy link
Owner

Choose a reason for hiding this comment

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

Why createProgram can be optional here?

Copy link
Author

Choose a reason for hiding this comment

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

In future, if more methods to intercept are added, it might be undesirable to require a module to implement no-op forwarders for all methods that are there. Also requiring every method would break existing middleware modules, as their export signature would no longer match with what ttypescript expects.

I'd suppose that in future this interface would become something like:

export interface Middleware {
    createProgram?: CreateProgramMiddleware;
    resolveModuleName?: ResolveModuleNameMiddleware;
    transpileModule?: TranspileModuleMiddleware;
    // etc...
}

And implementors would be able to choose what functions are they interested in.

}
export interface MiddlewareHead {
createProgram: CreateProgramMiddlewareHead;
}
}

export type OriginEntries = {
createProgram: typeof ts.createProgram;
}

export interface TransformerBasePlugin extends ts.Middleware {
before?: ts.TransformerFactory<ts.SourceFile>;
after?: ts.TransformerFactory<ts.SourceFile>;
afterDeclarations?: ts.TransformerFactory<ts.SourceFile | ts.Bundle>;
Expand All @@ -57,13 +74,18 @@ export type RawPattern = (
program: ts.Program,
config: {}
) => ts.Transformer<ts.SourceFile>;
export type MiddlewarePattern = (
config: {},
typescript: typeof ts
) => ts.Middleware;
export type PluginFactory =
| LSPattern
| ProgramPattern
| ConfigPattern
| CompilerOptionsPattern
| TypeCheckerPattern
| RawPattern;
| RawPattern
| MiddlewarePattern;

function createTransformerFromPattern({
typescript,
Expand All @@ -75,7 +97,7 @@ function createTransformerFromPattern({
typescript: typeof ts;
factory: PluginFactory;
config: PluginConfig;
program: ts.Program;
program?: ts.Program;
ls?: ts.LanguageService;
}): TransformerBasePlugin {
const { transform, after, afterDeclarations, name, type, ...cleanConfig } = config;
Expand All @@ -87,24 +109,32 @@ function createTransformerFromPattern({
ret = (factory as LSPattern)(ls, cleanConfig);
break;
case 'config':
if (!program) throw new Error(`Plugin ${transform} needs a Program`);
ret = (factory as ConfigPattern)(cleanConfig);
break;
case 'compilerOptions':
if (!program) throw new Error(`Plugin ${transform} needs a Program`);
ret = (factory as CompilerOptionsPattern)(program.getCompilerOptions(), cleanConfig);
break;
case 'checker':
if (!program) throw new Error(`Plugin ${transform} needs a Program`);
ret = (factory as TypeCheckerPattern)(program.getTypeChecker(), cleanConfig);
break;
case undefined:
case 'program':
if (!program) throw new Error(`Plugin ${transform} needs a Program`);
ret = (factory as ProgramPattern)(program, cleanConfig, {
ts: typescript,
addDiagnostic: addDiagnosticFactory(program),
});
break;
case 'raw':
if (!program) throw new Error(`Plugin ${transform} needs a Program`);
ret = (ctx: ts.TransformationContext) => (factory as RawPattern)(ctx, program, cleanConfig);
break;
case 'middleware':
ret = (factory as MiddlewarePattern)(cleanConfig, typescript);
break;
default:
return never(config.type);
}
Expand Down Expand Up @@ -174,9 +204,9 @@ export class PluginCreator {
program = params.program;
}
for (const config of this.configs) {
if (!config.transform) {
continue;
}
if (config.type === 'middleware') continue;
if (!config.transform) continue;

const factory = this.resolveFactory(config.transform, config.import);
// if recursion
if (factory === undefined) continue;
Expand All @@ -198,6 +228,51 @@ export class PluginCreator {
return chain;
}

private composeMiddlewareTransformers(inner: TransformerBasePlugin, outer: TransformerBasePlugin) {
inner.createProgram = this.composeMiddlewares(inner.createProgram, outer.createProgram);
}

private composeMiddlewares<F extends Function>(inner?: F, outer?: F): F {
if (!inner) {
throw new Error('inner middleware must exist');
}

if (!outer) {
return inner;
}

return (<A extends readonly any[]>(...args: A) => {
return outer(...args, (...newArgs: A) => {
newArgs = newArgs.length === 0 ? args : newArgs;

return inner(...newArgs)
});
}) as unknown as F;
}

createMiddlewares(originEntries: OriginEntries): ts.MiddlewareHead {
const chain: ts.MiddlewareHead = {
createProgram: (opts) => originEntries.createProgram(opts)
}

for (const config of this.configs) {
if (config.type !== 'middleware') continue;
if (!config.transform) continue;

const factory = this.resolveFactory(config.transform, config.import);
// if recursion
if (factory === undefined) continue;
const transformer = createTransformerFromPattern({
typescript: this.typescript,
factory,
config
});
this.composeMiddlewareTransformers(chain, transformer);
}

return chain;
}

private resolveFactory(transform: string, importKey: string = 'default'): PluginFactory | undefined {
if (
!tsNodeIncluded &&
Expand Down
80 changes: 40 additions & 40 deletions packages/ttypescript/src/patchCreateProgram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,52 +32,22 @@ export function addDiagnosticFactory(program: ts.Program) {
export function patchCreateProgram(tsm: typeof ts, forceReadConfig = false, projectDir = process.cwd()) {
const originCreateProgram = tsm.createProgram as any;

function createProgram(createProgramOptions: ts.CreateProgramOptions): ts.Program;
function createProgram(
rootNames: ReadonlyArray<string>,
options: ts.CompilerOptions,
host?: ts.CompilerHost,
oldProgram?: ts.Program,
configFileParsingDiagnostics?: ReadonlyArray<ts.Diagnostic>
): ts.Program;
function createProgram(
rootNamesOrOptions: ReadonlyArray<string> | ts.CreateProgramOptions,
options?: ts.CompilerOptions,
host?: ts.CompilerHost,
oldProgram?: ts.Program,
configFileParsingDiagnostics?: ReadonlyArray<ts.Diagnostic>
): ts.Program {
let rootNames;
let createOpts: ts.CreateProgramOptions | undefined;
if (!Array.isArray(rootNamesOrOptions)) {
createOpts = rootNamesOrOptions as ts.CreateProgramOptions;
}
if (createOpts) {
rootNames = createOpts.rootNames;
options = createOpts.options;
host = createOpts.host;
oldProgram = createOpts.oldProgram;
configFileParsingDiagnostics = createOpts.configFileParsingDiagnostics;
} else {
options = options!;
rootNames = rootNamesOrOptions as ReadonlyArray<string>;
}

function createProgramWithOpts(createProgramOptions: ts.CreateProgramOptions): ts.Program {
if (forceReadConfig) {
const info = getConfig(tsm, options, rootNames, projectDir);
options = info.compilerOptions;
if (createOpts) {
createOpts.options = options;
}
const info = getConfig(tsm, createProgramOptions.options, createProgramOptions.rootNames, projectDir);
createProgramOptions.options = info.compilerOptions;
projectDir = info.projectDir;
}
const program: ts.Program = createOpts
? originCreateProgram(createOpts)
: originCreateProgram(rootNames, options, host, oldProgram, configFileParsingDiagnostics);

const plugins = preparePluginsFromCompilerOptions(options.plugins);
const plugins = preparePluginsFromCompilerOptions(createProgramOptions.options.plugins);
const pluginCreator = new PluginCreator(tsm, plugins, projectDir);

const middlewares = pluginCreator.createMiddlewares({
createProgram: originCreateProgram
});

const program: ts.Program = middlewares.createProgram(createProgramOptions);

const originEmit = program.emit;
program.emit = function newEmit(
targetSourceFile?: ts.SourceFile,
Expand All @@ -100,7 +70,37 @@ export function patchCreateProgram(tsm: typeof ts, forceReadConfig = false, proj
};
return program;
}

function createProgram(createProgramOptions: ts.CreateProgramOptions): ts.Program;
function createProgram(
rootNames: ReadonlyArray<string>,
options: ts.CompilerOptions,
host?: ts.CompilerHost,
oldProgram?: ts.Program,
configFileParsingDiagnostics?: ReadonlyArray<ts.Diagnostic>
): ts.Program;
function createProgram(
createProgramOptionsOrRootNames: ts.CreateProgramOptions | ReadonlyArray<string>,
options?: ts.CompilerOptions,
host?: ts.CompilerHost,
oldProgram?: ts.Program,
configFileParsingDiagnostics?: ReadonlyArray<ts.Diagnostic>
): ts.Program {
if (Array.isArray(createProgramOptionsOrRootNames)) {
return createProgramWithOpts({
rootNames: createProgramOptionsOrRootNames as ReadonlyArray<string>,
options: options!,
host,
oldProgram,
configFileParsingDiagnostics
});
} else {
return createProgramWithOpts(createProgramOptionsOrRootNames as ts.CreateProgramOptions);
}
}

tsm.createProgram = createProgram;

return tsm;
}

Expand Down