From e21cb9e6218e137bbb3d126f2cf907420257074c Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Wed, 11 Jun 2025 15:13:58 +0200 Subject: [PATCH] Strip TS types ourselves Instead of using `ts-blank-space` we're using custom logic that closely follows that of https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js . That way we shrink the worker bundle by about 9x. --- packages/repl/package.json | 3 +- .../repl/src/lib/workers/bundler/index.ts | 2 +- ...ypescript-strip-types.ts => typescript.ts} | 4 +- .../repl/src/lib/workers/compiler/index.ts | 4 +- .../src/lib/workers/typescript-strip-types.ts | 260 ++++++++++++++++++ pnpm-lock.yaml | 17 +- 6 files changed, 273 insertions(+), 17 deletions(-) rename packages/repl/src/lib/workers/bundler/plugins/{typescript-strip-types.ts => typescript.ts} (73%) create mode 100644 packages/repl/src/lib/workers/typescript-strip-types.ts diff --git a/packages/repl/package.json b/packages/repl/package.json index 542602c38c..5a870a6129 100644 --- a/packages/repl/package.json +++ b/packages/repl/package.json @@ -90,6 +90,7 @@ "@replit/codemirror-vim": "^6.0.14", "@rich_harris/svelte-split-pane": "^2.0.0", "@rollup/browser": "^4.17.2", + "@sveltejs/acorn-typescript": "^1.0.0", "@sveltejs/site-kit": "workspace:*", "@sveltejs/svelte-json-tree": "^2.2.1", "acorn": "^8.11.3", @@ -98,12 +99,12 @@ "esrap": "^1.2.2", "icons": "workspace:*", "locate-character": "^3.0.0", + "magic-string": "^0.30.0", "marked": "^14.1.2", "resolve.exports": "^2.0.2", "svelte": "5.33.0", "tailwindcss": "^4.0.15", "tarparser": "^0.0.4", - "ts-blank-space": "^0.6.1", "zimmerframe": "^1.1.2" } } diff --git a/packages/repl/src/lib/workers/bundler/index.ts b/packages/repl/src/lib/workers/bundler/index.ts index aed968da13..c0ee964537 100644 --- a/packages/repl/src/lib/workers/bundler/index.ts +++ b/packages/repl/src/lib/workers/bundler/index.ts @@ -3,7 +3,7 @@ import { walk } from 'zimmerframe'; import '../patch_window'; import { rollup } from '@rollup/browser'; import { DEV } from 'esm-env'; -import typescript_strip_types from './plugins/typescript-strip-types'; +import typescript_strip_types from './plugins/typescript'; import commonjs from './plugins/commonjs'; import glsl from './plugins/glsl'; import json from './plugins/json'; diff --git a/packages/repl/src/lib/workers/bundler/plugins/typescript-strip-types.ts b/packages/repl/src/lib/workers/bundler/plugins/typescript.ts similarity index 73% rename from packages/repl/src/lib/workers/bundler/plugins/typescript-strip-types.ts rename to packages/repl/src/lib/workers/bundler/plugins/typescript.ts index 3f4991cbae..5a4aad0b21 100644 --- a/packages/repl/src/lib/workers/bundler/plugins/typescript-strip-types.ts +++ b/packages/repl/src/lib/workers/bundler/plugins/typescript.ts @@ -1,5 +1,5 @@ +import { strip_types } from '../../typescript-strip-types'; import type { Plugin } from '@rollup/browser'; -import tsBlankSpace from 'ts-blank-space'; const plugin: Plugin = { name: 'typescript-strip-types', @@ -8,7 +8,7 @@ const plugin: Plugin = { if (!match) return; return { - code: tsBlankSpace(code) + code: strip_types(code) }; } }; diff --git a/packages/repl/src/lib/workers/compiler/index.ts b/packages/repl/src/lib/workers/compiler/index.ts index 2f553312d5..2e361b5dee 100644 --- a/packages/repl/src/lib/workers/compiler/index.ts +++ b/packages/repl/src/lib/workers/compiler/index.ts @@ -1,8 +1,8 @@ import '@sveltejs/site-kit/polyfills'; import type { CompileResult } from 'svelte/compiler'; -import tsBlankSpace from 'ts-blank-space'; import type { ExposedCompilerOptions, File } from '../../Workspace.svelte'; import { load_svelte } from '../npm'; +import { strip_types } from '../typescript-strip-types'; // hack for magic-string and Svelte 4 compiler // do not put this into a separate module and import it, would be treeshaken in prod @@ -88,7 +88,7 @@ addEventListener('message', async (event) => { compilerOptions.experimental = { async: true }; } - const content = tsBlankSpace(file.contents); + const content = file.basename.endsWith('.ts') ? strip_types(file.contents) : file.contents; result = svelte.compileModule(content, compilerOptions); } diff --git a/packages/repl/src/lib/workers/typescript-strip-types.ts b/packages/repl/src/lib/workers/typescript-strip-types.ts new file mode 100644 index 0000000000..76d9630296 --- /dev/null +++ b/packages/repl/src/lib/workers/typescript-strip-types.ts @@ -0,0 +1,260 @@ +import * as acorn from 'acorn'; +import { walk, type Context, type Visitors } from 'zimmerframe'; +import { tsPlugin } from '@sveltejs/acorn-typescript'; +import MagicString from 'magic-string'; + +const ParserWithTS = acorn.Parser.extend(tsPlugin()); + +/** + * @param {FunctionExpression | FunctionDeclaration} node + * @param {Context} context + */ +function remove_this_param( + node: acorn.FunctionExpression | acorn.FunctionDeclaration, + context: Context +) { + const param = node.params[0] as any; + if (param?.type === 'Identifier' && param.name === 'this') { + if (param.typeAnnotation) { + // the type annotation is blanked by another visitor, do it in two parts to prevent an overwrite error + ts_blank_space(context, { start: param.start, end: param.typeAnnotation.start }); + ts_blank_space(context, { + start: param.typeAnnotation.end, + end: node.params[1]?.start || param.end + }); + } else { + ts_blank_space(context, { + start: param.start, + end: node.params[1]?.start || param.end + }); + } + } + return context.next(); +} + +function typescript_invalid_feature(node: any, feature: string) { + const e = new Error(`The REPL does not support ${feature}. Please remove it from your code.`); + // @ts-expect-error Our REPL error handling needs this + e.position = [node.start, node.end]; + throw e; +} + +const empty = { + type: 'EmptyStatement' +}; + +function ts_blank_space(context: Context, node: any): void { + const { start, end } = node; + let i = start; + while (i < end) { + // Skip whitespace + while (i < end && /\s/.test(context.state.ms.original[i])) i++; + if (i >= end) break; + // Find next whitespace or end + let j = i + 1; + while (j < end && !/\s/.test(context.state.ms.original[j])) j++; + context.state.ms.overwrite(i, j, ' '.repeat(j - i)); + i = j; + } +} + +const visitors: Visitors = { + _(node, context) { + if (node.typeAnnotation) ts_blank_space(context, node.typeAnnotation); + if (node.typeParameters) ts_blank_space(context, node.typeParameters); + if (node.typeArguments) ts_blank_space(context, node.typeArguments); + if (node.returnType) ts_blank_space(context, node.returnType); + if (node.accessibility) { + ts_blank_space(context, { start: node.start, end: node.start + node.accessibility.length }); + } + + context.next(); + }, + Decorator(node, context) { + ts_blank_space(context, node); + }, + ImportDeclaration(node, context) { + if (node.importKind === 'type') { + ts_blank_space(context, node); + return empty; + } + + if (node.specifiers?.length > 0) { + const specifiers = node.specifiers.filter((s: any, i: number) => { + if (s.importKind !== 'type') return true; + + ts_blank_space(context, { + start: s.start, + end: node.specifiers[i + 1]?.start || s.end + }); + }); + + if (specifiers.length === 0) { + ts_blank_space(context, node); + } + } + }, + ExportNamedDeclaration(node, context) { + if (node.exportKind === 'type') { + ts_blank_space(context, node); + return empty; + } + + if (node.declaration) { + const result = context.next(); + if (result?.declaration?.type === 'EmptyStatement') { + ts_blank_space(context, node); + return empty; + } + return result; + } + + if (node.specifiers) { + const specifiers = node.specifiers.filter((s: any, i: number) => { + if (s.exportKind !== 'type') return true; + + ts_blank_space(context, { + start: s.start, + end: node.specifiers[i + 1]?.start || s.end + }); + }); + + if (specifiers.length === 0) { + ts_blank_space(context, node); + } + return; + } + }, + ExportDefaultDeclaration(node, context) { + if (node.exportKind === 'type') { + ts_blank_space(context, node); + return empty; + } else { + context.next(); + } + }, + ExportAllDeclaration(node, context) { + if (node.exportKind === 'type') { + ts_blank_space(context, node); + return empty; + } else { + context.next(); + } + }, + PropertyDefinition(node, context) { + if (node.accessor) { + typescript_invalid_feature(node, 'accessor fields (related TSC proposal is not stage 4 yet)'); + } else { + context.next(); + } + }, + TSAsExpression(node, context) { + ts_blank_space(context, { start: node.expression.end, end: node.end }); + context.visit(node.expression); + }, + TSSatisfiesExpression(node, context) { + ts_blank_space(context, { start: node.expression.end, end: node.end }); + context.visit(node.expression); + }, + TSNonNullExpression(node, context) { + ts_blank_space(context, { start: node.expression.end, end: node.end }); + context.visit(node.expression); + }, + TSInterfaceDeclaration(node, context) { + ts_blank_space(context, node); + return empty; + }, + TSTypeAliasDeclaration(node, context) { + ts_blank_space(context, node); + return empty; + }, + TSTypeAssertion(node, context) { + ts_blank_space(context, { start: node.start, end: node.expression.start }); + context.visit(node.expression); + }, + TSEnumDeclaration(node, context) { + typescript_invalid_feature(node, 'enums'); + }, + TSParameterProperty(node, context) { + if ((node.readonly || node.accessibility) && context.path.at(-2)?.kind === 'constructor') { + typescript_invalid_feature(node, 'accessibility modifiers on constructor parameters'); + } + ts_blank_space(context, { start: node.start, end: node.parameter.start }); + context.visit(node.parameter); + }, + TSInstantiationExpression(node, context) { + ts_blank_space(context, { start: node.start, end: node.expression.start }); + context.visit(node.expression); + }, + FunctionExpression: remove_this_param, + FunctionDeclaration: remove_this_param, + TSDeclareFunction(node, context) { + ts_blank_space(context, node); + return empty; + }, + ClassDeclaration(node, context) { + if (node.declare || node.abstract) { + ts_blank_space(context, node); + return empty; + } + + if (node.implements?.length) { + const implements_keyword_start = context.state.ms.original.lastIndexOf( + 'implements', + node.implements[0].start + ); + ts_blank_space(context, { + start: implements_keyword_start, + end: node.implements[node.implements.length - 1].end + }); + } + context.next(); + }, + MethodDefinition(node, context) { + if (node.abstract) { + ts_blank_space(context, { start: node.start, end: node.start + 'abstract'.length }); + return empty; + } + context.next(); + }, + VariableDeclaration(node, context) { + if (node.declare) { + ts_blank_space(context, node); + return empty; + } + context.next(); + }, + TSModuleDeclaration(node, context) { + if (!node.body) { + ts_blank_space(context, node); + return; + } + // namespaces can contain non-type nodes + const cleaned = node.body.body.map((entry) => context.visit(entry)); + if (cleaned.some((entry) => entry !== empty)) { + typescript_invalid_feature(node, 'namespaces with non-type nodes'); + } + ts_blank_space(context, node); + } +}; + +/** + * Strips type-only constructs from TypeScript code and replaces them with blank spaces. + * Errors on non-type constructs that are not supported in the REPL. + * + * This implementation closely follows the logic of https://github.com/sveltejs/svelte/blob/main/packages/svelte/src/compiler/phases/1-parse/remove_typescript_nodes.js + * + * Used instead of`ts-blank-space` because the latter means we need to bundle all of TypeScript, which increases the worker bundles by 9x. + */ +export function strip_types(code: string): string { + const ms = new MagicString(code); + const ast = ParserWithTS.parse(code, { + sourceType: 'module', + ecmaVersion: 16, + locations: true + }); + + walk(ast, { ms }, visitors); + + return ms.toString(); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7fb4b46b73..16d6789b8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -257,6 +257,9 @@ importers: '@rollup/browser': specifier: ^4.17.2 version: 4.17.2 + '@sveltejs/acorn-typescript': + specifier: ^1.0.0 + version: 1.0.5(acorn@8.14.1) '@sveltejs/site-kit': specifier: workspace:* version: link:../site-kit @@ -281,6 +284,9 @@ importers: locate-character: specifier: ^3.0.0 version: 3.0.0 + magic-string: + specifier: ^0.30.0 + version: 0.30.12 marked: specifier: ^14.1.2 version: 14.1.2 @@ -296,9 +302,6 @@ importers: tarparser: specifier: ^0.0.4 version: 0.0.4 - ts-blank-space: - specifier: ^0.6.1 - version: 0.6.1 zimmerframe: specifier: ^1.1.2 version: 1.1.2 @@ -3067,10 +3070,6 @@ packages: trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} - ts-blank-space@0.6.1: - resolution: {integrity: sha512-LcM3W5HEyzTaXUeQITV8ploUOGe+zuuoFYsCfPscFLhx3bZn2sSfHMKxsULVG/zA7an9UhReiHv4Kk/6QzlpXQ==} - engines: {node: '>=18.0.0'} - tslib@2.6.3: resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} @@ -5933,10 +5932,6 @@ snapshots: trim-lines@3.0.1: {} - ts-blank-space@0.6.1: - dependencies: - typescript: 5.8.2 - tslib@2.6.3: {} tsx@4.19.0: