-
Notifications
You must be signed in to change notification settings - Fork 6.5k
feat: enable Twoslash on Cloudflare #8837
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,37 @@ import remarkTableTitles from '../util/table'; | |
| // Reference: https://github.com/nodejs/nodejs.org/pull/7896#issuecomment-3009480615 | ||
| const OPEN_NEXT_CLOUDFLARE = 'Cloudflare' in global; | ||
|
|
||
| /** | ||
| * Creates a Twoslash instance backed by a virtual filesystem for environments | ||
| * without real filesystem access (e.g. Cloudflare Workers). | ||
| * | ||
| * Uses a pre-built JSON map of TypeScript lib declarations and @types/node | ||
| * generated at build time by `scripts/twoslash-fsmap/index.mjs`. | ||
| */ | ||
| async function createVfsTwoslasher() { | ||
| const [{ createTwoslasher }, ts, fsMapJson] = await Promise.all([ | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We don't need promise all here, let's simply make one import statement per line and let's not mix then() here. |
||
| import('twoslash/core'), | ||
| import('typescript').then(m => m.default), | ||
| import('../generated/twoslash-fsmap.json', { with: { type: 'json' } }).then( | ||
| m => m.default | ||
| ), | ||
| ]); | ||
|
|
||
| const fsMap = new Map(Object.entries(fsMapJson)); | ||
|
|
||
| return createTwoslasher({ | ||
| fsMap, | ||
| tsModule: ts, | ||
| vfsRoot: '/', | ||
| compilerOptions: { | ||
| moduleResolution: ts.ModuleResolutionKind.Bundler, | ||
| // Explicitly include @types/node so that the VFS resolves Node.js | ||
| // globals and `node:*` module imports from the bundled declarations. | ||
| types: ['node'], | ||
| }, | ||
|
dario-piotrowicz marked this conversation as resolved.
|
||
| }); | ||
| } | ||
|
|
||
| // Shiki is created out here to avoid an async rehype plugin | ||
| const singletonShiki = await rehypeShikiji({ | ||
| // We use the faster WASM engine on the server instead of the web-optimized version. | ||
|
|
@@ -25,8 +56,15 @@ const singletonShiki = await rehypeShikiji({ | |
| // for security reasons. | ||
| wasm: !OPEN_NEXT_CLOUDFLARE, | ||
|
|
||
| // TODO(@avivkeller): Find a way to enable Twoslash w/ a VFS on Cloudflare | ||
| twoslash: !OPEN_NEXT_CLOUDFLARE, | ||
| twoslash: true, | ||
|
|
||
| // On Cloudflare Workers, the default filesystem-backed Twoslash cannot work | ||
| // because there is no real filesystem. Instead, we provide a custom twoslasher | ||
| // backed by an in-memory VFS pre-populated at build time with TypeScript | ||
| // lib declarations and @types/node. | ||
| twoslashOptions: OPEN_NEXT_CLOUDFLARE | ||
| ? { twoslasher: await createVfsTwoslasher() } | ||
| : undefined, | ||
| }); | ||
|
|
||
| /** | ||
|
|
||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think we really need any of this? I'm fairly sue twoslash supports using vfs out of the box? Since they have this CDN thing https://twoslash.netlify.app/packages/cdn (for example) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,73 @@ | ||
| 'use strict'; | ||
|
|
||
| import { readdirSync, readFileSync } from 'node:fs'; | ||
| import { createRequire } from 'node:module'; | ||
| import { dirname, join, resolve } from 'node:path'; | ||
|
|
||
| const require = createRequire(import.meta.url); | ||
|
|
||
| /** | ||
| * Recursively collects all `.d.ts` files from a directory into the fsMap. | ||
| * | ||
| * @param {Record<string, string>} fsMap The map to populate | ||
| * @param {string} dir The directory to walk | ||
| * @param {string} virtualPrefix The virtual path prefix (e.g., "/node_modules/@types/node") | ||
| * @param {string} baseDir The base directory for computing relative paths | ||
| */ | ||
| function collectDtsFiles(fsMap, dir, virtualPrefix, baseDir) { | ||
| const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => | ||
| a.name.localeCompare(b.name) | ||
| ); | ||
|
|
||
| for (const entry of entries) { | ||
| const fullPath = join(dir, entry.name); | ||
|
|
||
| if (entry.isDirectory()) { | ||
| collectDtsFiles(fsMap, fullPath, virtualPrefix, baseDir); | ||
| } else if (entry.isFile() && /\.d\.([^.]+\.)?[cm]?ts$/i.test(entry.name)) { | ||
| const relativePath = fullPath.slice(baseDir.length).replace(/\\/g, '/'); | ||
| const virtualPath = `${virtualPrefix}${relativePath}`; | ||
|
|
||
| fsMap[virtualPath] = readFileSync(fullPath, 'utf8'); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** | ||
| * Generates a virtual filesystem map containing all TypeScript library | ||
| * declaration files and `@types/node` declarations needed for Twoslash | ||
| * to run without real filesystem access (e.g., on Cloudflare Workers). | ||
| * | ||
| * @returns {Record<string, string>} A map of virtual paths to file contents | ||
| */ | ||
| export default function generateTwoslashFsMap() { | ||
| const fsMap = {}; | ||
|
|
||
| // 1. Collect TypeScript lib .d.ts files | ||
| // These are keyed as "/lib.es5.d.ts", "/lib.dom.d.ts", etc. | ||
| // (matching the convention used by @typescript/vfs) | ||
| const tsLibDir = dirname(require.resolve('typescript/lib/lib.d.ts')); | ||
| const tsLibFiles = readdirSync(tsLibDir) | ||
| .filter(f => f.startsWith('lib.') && /\.d\.([^.]+\.)?[cm]?ts$/i.test(f)) | ||
| .sort(); | ||
|
|
||
| for (const file of tsLibFiles) { | ||
| fsMap[`/${file}`] = readFileSync(join(tsLibDir, file), 'utf8'); | ||
| } | ||
|
|
||
| // 2. Collect @types/node .d.ts files | ||
| // These are keyed as "/node_modules/@types/node/index.d.ts", etc. | ||
| const typesNodeDir = resolve( | ||
| require.resolve('@types/node/package.json'), | ||
| '..' | ||
| ); | ||
|
|
||
| collectDtsFiles( | ||
| fsMap, | ||
| typesNodeDir, | ||
| '/node_modules/@types/node', | ||
| typesNodeDir | ||
| ); | ||
|
|
||
| return fsMap; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| 'use strict'; | ||
|
|
||
| import { mkdirSync, writeFileSync } from 'node:fs'; | ||
|
|
||
| import generateTwoslashFsMap from './generate.mjs'; | ||
|
|
||
| const fsMap = generateTwoslashFsMap(); | ||
|
|
||
| const outputPath = new URL( | ||
| '../../generated/twoslash-fsmap.json', | ||
| import.meta.url | ||
| ); | ||
|
dario-piotrowicz marked this conversation as resolved.
|
||
|
|
||
| mkdirSync(new URL('.', outputPath), { recursive: true }); | ||
| writeFileSync(outputPath, JSON.stringify(fsMap), 'utf8'); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is so specific that should probably reside in another file IMO