From 329de2ef9456d89715964e62fccf14d21f9ae09e Mon Sep 17 00:00:00 2001 From: Pessimistress Date: Wed, 29 Jul 2026 11:59:25 -0700 Subject: [PATCH 1/2] Add webgpu transform --- .../src/configuration/get-esbuild-config.ts | 1 + .../ts-plugins/docs/ts-transform-webgpu.md | 82 ++++++ modules/ts-plugins/package.json | 4 + .../src/ts-transform-webgpu/index.ts | 273 ++++++++++++++++++ modules/ts-plugins/test/index.ts | 1 + .../test/ts-transform-webgpu.spec.ts | 150 ++++++++++ 6 files changed, 511 insertions(+) create mode 100644 modules/ts-plugins/docs/ts-transform-webgpu.md create mode 100644 modules/ts-plugins/src/ts-transform-webgpu/index.ts create mode 100644 modules/ts-plugins/test/ts-transform-webgpu.spec.ts diff --git a/modules/dev-tools/src/configuration/get-esbuild-config.ts b/modules/dev-tools/src/configuration/get-esbuild-config.ts index 784d95b..82a1449 100644 --- a/modules/dev-tools/src/configuration/get-esbuild-config.ts +++ b/modules/dev-tools/src/configuration/get-esbuild-config.ts @@ -81,6 +81,7 @@ export async function getCJSExportConfig(opts: { target: 'node16', packages: 'external', sourcemap: true, + sourcesContent: false, logLevel: 'info' }; } diff --git a/modules/ts-plugins/docs/ts-transform-webgpu.md b/modules/ts-plugins/docs/ts-transform-webgpu.md new file mode 100644 index 0000000..41edd9e --- /dev/null +++ b/modules/ts-plugins/docs/ts-transform-webgpu.md @@ -0,0 +1,82 @@ +# WebGPU transform + +`@vis.gl/ts-plugins/ts-transform-webgpu` produces either WebGPU-enabled JavaScript or a +WebGL-only variant from the same TypeScript sources. WebGL-only builds replace WebGPU-specific +expressions with constants and stubs so that downstream minifiers and tree-shakers can remove +unreachable code and shader sources. + +## Configuration + +Add the transform to `compilerOptions.plugins`: + +```json +{ + "compilerOptions": { + "plugins": [ + { + "transform": "@vis.gl/ts-plugins/ts-transform-webgpu", + "webGPUEnabled": false + } + ] + } +} +``` + +`webGPUEnabled` defaults to `false`. Set it to `true` for a full WebGPU-enabled build and to +`false` for a WebGL-only build. + +## Transformations + +The identifier `__WEBGPU_ENABLED` is replaced with the configured boolean: + +```ts +const isWebGPUEnabled = __WEBGPU_ENABLED; +``` + +When `webGPUEnabled` is `false`, the transform also: + +- Replaces `device.type === 'webgpu'` and `.device.type === 'webgpu'` with `false`. +- Replaces template literals immediately preceded by `/* wgsl */` with `null`, including + interpolated templates. +- Rewrites JavaScript values exported from files ending in `.wgsl.ts`. Exported variables become + `null`, and exported functions become `() => null`. Exported type aliases and interfaces remain + available to TypeScript declaration emit. + +For example: + +```ts +const source = /* wgsl */ ` + @vertex fn main() {} +`; + +const backend = device.type === 'webgpu' ? webgpuBackend : webglBackend; +``` + +becomes: + +```ts +const source = null; +const backend = webglBackend; +``` + +The transform folds conditional expressions whose condition is a literal boolean. It also folds +conditions that reference a `const` initialized directly to `true` or `false`: + +```ts +const isWebGPU = false; +const backend = isWebGPU ? webgpuBackend : webglBackend; +``` + +becomes: + +```ts +const isWebGPU = false; +const backend = webglBackend; +``` + +## Scope + +This transform operates during TypeScript JavaScript emit. It does not change application runtime +feature detection, package exports, declaration routing, or bundler configuration. Build tooling is +responsible for selecting `webGPUEnabled`, placing each output in the appropriate package +directory, and exposing it through package metadata. diff --git a/modules/ts-plugins/package.json b/modules/ts-plugins/package.json index 87489b7..13192c7 100644 --- a/modules/ts-plugins/package.json +++ b/modules/ts-plugins/package.json @@ -39,6 +39,10 @@ "./ts-transform-inline-webgl-constants": { "require": "./dist/ts-transform-inline-webgl-constants/index.cjs", "import": "./dist/ts-transform-inline-webgl-constants/index.js" + }, + "./ts-transform-webgpu": { + "require": "./dist/ts-transform-webgpu/index.cjs", + "import": "./dist/ts-transform-webgpu/index.js" } }, "types": "./dist/index.d.ts", diff --git a/modules/ts-plugins/src/ts-transform-webgpu/index.ts b/modules/ts-plugins/src/ts-transform-webgpu/index.ts new file mode 100644 index 0000000..de5bace --- /dev/null +++ b/modules/ts-plugins/src/ts-transform-webgpu/index.ts @@ -0,0 +1,273 @@ +/** + * TypeScript transform that controls whether WebGPU-only code is emitted. + */ +/* eslint-disable complexity, max-depth */ +import type { + Expression, + Identifier, + Node, + Program, + SourceFile, + Statement, + TransformationContext, + Transformer +} from 'typescript'; +import type {PluginConfig, TransformerExtras} from 'ts-patch'; + +type WebGPUPluginConfig = PluginConfig & { + /** Whether the emitted JavaScript includes WebGPU code paths. */ + webGPUEnabled?: boolean; +}; + +export default function transformWebGPU( + program: Program, + pluginConfig: WebGPUPluginConfig, + {ts}: TransformerExtras +) { + const webGPUEnabled = pluginConfig.webGPUEnabled ?? false; + + return (context: TransformationContext): Transformer => { + const {factory} = context; + + return (sourceFile) => { + if (!webGPUEnabled && sourceFile.fileName.endsWith('.wgsl.ts')) { + return replaceWGSLExports(sourceFile); + } + + function isWebGPUDeviceCheck(node: Node): boolean { + if ( + !ts.isBinaryExpression(node) || + node.operatorToken.kind !== ts.SyntaxKind.EqualsEqualsEqualsToken || + !ts.isStringLiteral(node.right) || + node.right.text !== 'webgpu' || + !ts.isPropertyAccessExpression(node.left) || + node.left.name.text !== 'type' + ) { + return false; + } + + const device = node.left.expression; + return ( + (ts.isIdentifier(device) && device.text === 'device') || + (ts.isPropertyAccessExpression(device) && device.name.text === 'device') + ); + } + + function visit(node: Node): Node { + if (!webGPUEnabled && ts.isVariableStatement(node)) { + const declarations = node.declarationList.declarations; + if ( + declarations.some( + (declaration) => + declaration.initializer && isWGSLAnnotatedTemplate(declaration.initializer) + ) + ) { + const replacement = factory.createVariableStatement( + node.modifiers, + factory.createVariableDeclarationList( + declarations.map((declaration) => + factory.createVariableDeclaration( + declaration.name, + declaration.exclamationToken, + declaration.type, + declaration.initializer && isWGSLAnnotatedTemplate(declaration.initializer) + ? factory.createNull() + : (ts.visitNode(declaration.initializer, visit) as Expression | undefined) + ) + ), + node.declarationList.flags + ) + ); + ts.setTextRange(replacement, node); + return replacement; + } + } + if (!webGPUEnabled && isWGSLAnnotatedTemplate(node)) { + const replacement = factory.createNull(); + ts.setEmitFlags(replacement, ts.EmitFlags.NoComments); + return replacement; + } + if (ts.isConditionalExpression(node)) { + const condition = ts.visitNode(node.condition, visit); + if (condition.kind === ts.SyntaxKind.TrueKeyword) { + return ts.visitNode(node.whenTrue, visit); + } + if (condition.kind === ts.SyntaxKind.FalseKeyword) { + return ts.visitNode(node.whenFalse, visit); + } + return factory.updateConditionalExpression( + node, + condition as Expression, + node.questionToken, + ts.visitNode(node.whenTrue, visit) as Expression, + node.colonToken, + ts.visitNode(node.whenFalse, visit) as Expression + ); + } + if (ts.isIdentifier(node) && node.text === '__WEBGPU_ENABLED') { + return webGPUEnabled ? factory.createTrue() : factory.createFalse(); + } + if (!webGPUEnabled && isWebGPUDeviceCheck(node)) { + return factory.createFalse(); + } + return ts.visitEachChild(node, visit, context); + } + + function isWGSLAnnotatedTemplate(node: Node): boolean { + if (!ts.isNoSubstitutionTemplateLiteral(node) && !ts.isTemplateExpression(node)) { + return false; + } + if (node.pos < 0) { + return false; + } + + const leadingTrivia = sourceFile.text.slice(node.getFullStart(), node.getStart(sourceFile)); + return /\/\*\s*wgsl\s*\*\/\s*$/i.test(leadingTrivia); + } + + const transformedSourceFile = ts.visitEachChild(sourceFile, visit, context); + return foldBooleanConstants(transformedSourceFile); + + function foldBooleanConstants(file: SourceFile): SourceFile { + const booleanConstants = new Map(); + + function collect(node: Node): void { + if (ts.isVariableStatement(node) && node.declarationList.flags & ts.NodeFlags.Const) { + for (const declaration of node.declarationList.declarations) { + if ( + ts.isIdentifier(declaration.name) && + declaration.initializer && + (declaration.initializer.kind === ts.SyntaxKind.TrueKeyword || + declaration.initializer.kind === ts.SyntaxKind.FalseKeyword) + ) { + const symbol = program.getTypeChecker().getSymbolAtLocation(declaration.name); + if (symbol) { + booleanConstants.set( + symbol, + declaration.initializer.kind === ts.SyntaxKind.TrueKeyword + ); + } + } + } + } + ts.forEachChild(node, collect); + } + + // Resolve symbols from the original tree. Nodes synthesized by an earlier + // transform pass are not always connected to the program's type checker. + collect(sourceFile); + + function fold(node: Node): Node { + if (ts.isConditionalExpression(node)) { + const condition = ts.visitNode(node.condition, fold); + const constantValue = getBooleanConstant(condition); + if (constantValue === true) { + return ts.visitNode(node.whenTrue, fold); + } + if (constantValue === false) { + return ts.visitNode(node.whenFalse, fold); + } + return factory.updateConditionalExpression( + node, + condition as Expression, + node.questionToken, + ts.visitNode(node.whenTrue, fold) as Expression, + node.colonToken, + ts.visitNode(node.whenFalse, fold) as Expression + ); + } + return ts.visitEachChild(node, fold, context); + } + + function getBooleanConstant(node: Node): boolean | undefined { + if (node.kind === ts.SyntaxKind.TrueKeyword) { + return true; + } + if (node.kind === ts.SyntaxKind.FalseKeyword) { + return false; + } + if (ts.isIdentifier(node)) { + const symbol = program.getTypeChecker().getSymbolAtLocation(node); + return symbol && booleanConstants.get(symbol); + } + return undefined; + } + + return ts.visitEachChild(file, fold, context); + } + + function replaceWGSLExports(wgslSourceFile: SourceFile): SourceFile { + const statements: Statement[] = []; + + for (const statement of wgslSourceFile.statements) { + if (ts.isExportAssignment(statement)) { + statements.push( + factory.updateExportAssignment(statement, statement.modifiers, factory.createNull()) + ); + } else if (ts.isFunctionDeclaration(statement) && hasExportModifier(statement)) { + const stub = createNullFunction(); + if (hasDefaultModifier(statement)) { + statements.push(factory.createExportAssignment(undefined, false, stub)); + } else if (statement.name) { + statements.push(createExportedConstant(statement.name, stub)); + } + } else if (ts.isVariableStatement(statement) && hasExportModifier(statement)) { + for (const declaration of statement.declarationList.declarations) { + if (ts.isIdentifier(declaration.name)) { + statements.push(createExportedConstant(declaration.name, factory.createNull())); + } + } + } else if ( + (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) && + hasExportModifier(statement) + ) { + statements.push(statement); + } + } + + return factory.updateSourceFile(wgslSourceFile, statements); + } + + function createExportedConstant(name: Identifier, initializer: Expression) { + return factory.createVariableStatement( + [factory.createModifier(ts.SyntaxKind.ExportKeyword)], + factory.createVariableDeclarationList( + [factory.createVariableDeclaration(name, undefined, undefined, initializer)], + ts.NodeFlags.Const + ) + ); + } + + function createNullFunction() { + return factory.createArrowFunction( + undefined, + undefined, + [], + undefined, + factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), + factory.createNull() + ); + } + + function hasExportModifier(node: Node): boolean { + return ts.canHaveModifiers(node) + ? Boolean( + ts + .getModifiers(node) + ?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword) + ) + : false; + } + + function hasDefaultModifier(node: Node): boolean { + return ts.canHaveModifiers(node) + ? Boolean( + ts + .getModifiers(node) + ?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) + ) + : false; + } + }; + }; +} diff --git a/modules/ts-plugins/test/index.ts b/modules/ts-plugins/test/index.ts index b1180e7..972393c 100644 --- a/modules/ts-plugins/test/index.ts +++ b/modules/ts-plugins/test/index.ts @@ -2,3 +2,4 @@ import './ts-transform-version-inline.spec'; import './ts-transform-append-extension.spec'; import './ts-transform-remove-glsl-comments/index.spec'; import './ts-transform-inline-webgl-constants.spec'; +import './ts-transform-webgpu.spec'; diff --git a/modules/ts-plugins/test/ts-transform-webgpu.spec.ts b/modules/ts-plugins/test/ts-transform-webgpu.spec.ts new file mode 100644 index 0000000..b8af211 --- /dev/null +++ b/modules/ts-plugins/test/ts-transform-webgpu.spec.ts @@ -0,0 +1,150 @@ +import test from 'tape-promise/tape'; +import ts from 'typescript'; +import type {PluginConfig} from 'ts-patch'; +import {transpile, assertSourceEqual} from './test-transformer.js'; +// @ts-expect-error Aliased import, remapped to valid path in esm-loader +import transformWebGPU from '@vis.gl/ts-plugins/ts-transform-webgpu'; + +test('ts-transform-webgpu: strips WebGPU code', (t) => { + const result = transpileWithProgram({ + source: `\ +const plain = \`keep me\`; +const shader = /* wgsl */ \`shader source\`; +const interpolated = /* wgsl */ \`shader \${plain}\`; +const supported = __WEBGPU_ENABLED; +const direct = device.type === 'webgpu' ? 'webgpu' : 'webgl'; +const nested = context.device.type === 'webgpu'; +const isWebGPU = false; +const backend = isWebGPU ? 'webgpu' : 'webgl'; +`, + transformer: transformWebGPU, + config: {webGPUEnabled: false} + }); + + t.is( + assertSourceEqual( + result, + `\ +const plain = \`keep me\`; +const shader = null; +const interpolated = null; +const supported = false; +const direct = 'webgl'; +const nested = false; +const isWebGPU = false; +const backend = 'webgl'; +` + ), + true + ); + t.end(); +}); + +function transpileWithProgram({ + source, + transformer, + config +}: { + source: string; + transformer: Function; + config: PluginConfig; +}): string { + const fileName = '/test.ts'; + const compilerOptions: ts.CompilerOptions = { + module: ts.ModuleKind.ESNext, + target: ts.ScriptTarget.ESNext + }; + const sourceFile = ts.createSourceFile( + fileName, + source, + ts.ScriptTarget.ESNext, + true, + ts.ScriptKind.TS + ); + const host = ts.createCompilerHost(compilerOptions); + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (requestedFileName, languageVersion, ...args) => + requestedFileName === fileName + ? sourceFile + : getSourceFile(requestedFileName, languageVersion, ...args); + host.fileExists = (requestedFileName) => + requestedFileName === fileName || ts.sys.fileExists(requestedFileName); + host.readFile = (requestedFileName) => + requestedFileName === fileName ? source : ts.sys.readFile(requestedFileName); + + const program = ts.createProgram([fileName], compilerOptions, host); + let output = ''; + program.emit( + undefined, + (outputFileName, text) => { + if (outputFileName.endsWith('.js')) { + output = text; + } + }, + undefined, + false, + { + before: [transformer(program, config, {ts})] + } + ); + return output; +} + +test('ts-transform-webgpu: preserves WebGPU code', (t) => { + const result = transpile({ + source: `\ +const shader = /* wgsl */ \`shader source\`; +const supported = __WEBGPU_ENABLED; +const backend = supported ? 'webgpu' : 'webgl'; +`, + transformer: transformWebGPU, + config: {webGPUEnabled: true} + }); + + t.is( + assertSourceEqual( + result, + `\ +const shader = /* wgsl */ \`shader source\`; +const supported = true; +const backend = supported ? 'webgpu' : 'webgl'; +` + ), + true + ); + t.end(); +}); + +test('ts-transform-webgpu: replaces WGSL module exports', (t) => { + const result = transpile({ + sourceFileName: 'shader.wgsl.ts', + source: `\ +export const source = \`shader source\`; +export function getShader() { + return source; +} +export default function createShader() { + return source; +} +export type ShaderOptions = {enabled: boolean}; +export interface ShaderModule { + source: string; +} +`, + transformer: transformWebGPU, + config: {webGPUEnabled: false} + }); + + t.is( + assertSourceEqual( + result, + `\ +export const source = null; +export const getShader = () => null; +export default () => null; +` + ), + true + ); + t.end(); +}); From 4c84e99a4f4ea073550ebaacdd567acb0e688975 Mon Sep 17 00:00:00 2001 From: Pessimistress Date: Wed, 29 Jul 2026 12:12:38 -0700 Subject: [PATCH 2/2] fix CI --- .github/workflows/test.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ac9b680..cd6978b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -12,17 +12,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [18] + node-version: [22] steps: - uses: actions/checkout@v2.1.1 - - uses: c-hive/gha-yarn-cache@v1 - - name: Set up Node ${{ matrix.node-version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + cache: yarn + cache-dependency-path: yarn.lock - name: Bootstrap run: | @@ -38,4 +38,4 @@ jobs: uses: coverallsapp/github-action@master with: github-token: ${{ secrets.GITHUB_TOKEN }} - path-to-lcov: ${{ github.workspace }}/coverage/lcov.info \ No newline at end of file + path-to-lcov: ${{ github.workspace }}/coverage/lcov.info