Skip to content

Commit 4f469fb

Browse files
feat: enable Twoslash on Cloudflare (#8837)
* feat: enable Twoslash on Cloudflare * move `createTwoslasher` to its own file * Simplify imports -- remove Promise.all and .then() * Simplify generate.mjs using @typescript/vfs
1 parent 5bf21e8 commit 4f469fb

9 files changed

Lines changed: 196 additions & 40 deletions

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ apps/site/build
1414
apps/site/public/blog-data.json
1515
apps/site/next-env.d.ts
1616

17+
# Generated Build Artifacts
18+
apps/site/generated
19+
1720
# Test Runner
1821
junit.xml
1922
lcov.info
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
'use strict';
2+
3+
/**
4+
* Creates a Twoslash instance backed by a virtual filesystem for environments
5+
* without real filesystem access (e.g. Cloudflare Workers).
6+
*
7+
* Uses a pre-built JSON map of TypeScript lib declarations and @types/node
8+
* generated at build time by `scripts/twoslash-fsmap/index.mjs`.
9+
*/
10+
export async function createVfsTwoslasher() {
11+
const { createTwoslasher } = await import('twoslash/core');
12+
const ts = (await import('typescript')).default;
13+
const fsMapJson = (
14+
await import('../generated/twoslash-fsmap.json', { with: { type: 'json' } })
15+
).default;
16+
17+
const fsMap = new Map(Object.entries(fsMapJson));
18+
19+
return createTwoslasher({
20+
fsMap,
21+
tsModule: ts,
22+
vfsRoot: '/',
23+
compilerOptions: {
24+
moduleResolution: ts.ModuleResolutionKind.Bundler,
25+
// Explicitly include @types/node so that the VFS resolves Node.js
26+
// globals and `node:*` module imports from the bundled declarations.
27+
types: ['node'],
28+
},
29+
});
30+
}

apps/site/mdx/plugins.mjs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import rehypeSlug from 'rehype-slug';
77
import remarkGfm from 'remark-gfm';
88
import readingTime from 'remark-reading-time';
99

10+
import { createVfsTwoslasher } from './create-vfs-twoslasher.mjs';
1011
import remarkTableTitles from '../util/table';
1112

1213
// TODO(@avivkeller): When available, use `OPEN_NEXT_CLOUDFLARE` environment
@@ -25,8 +26,15 @@ const singletonShiki = await rehypeShikiji({
2526
// for security reasons.
2627
wasm: !OPEN_NEXT_CLOUDFLARE,
2728

28-
// TODO(@avivkeller): Find a way to enable Twoslash w/ a VFS on Cloudflare
29-
twoslash: !OPEN_NEXT_CLOUDFLARE,
29+
twoslash: true,
30+
31+
// On Cloudflare Workers, the default filesystem-backed Twoslash cannot work
32+
// because there is no real filesystem. Instead, we provide a custom twoslasher
33+
// backed by an in-memory VFS pre-populated at build time with TypeScript
34+
// lib declarations and @types/node.
35+
twoslashOptions: OPEN_NEXT_CLOUDFLARE
36+
? { twoslasher: await createVfsTwoslasher() }
37+
: undefined,
3038
});
3139

3240
/**

apps/site/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@
22
"name": "@node-core/website",
33
"type": "module",
44
"scripts": {
5-
"prebuild": "node --run build:blog-data",
5+
"prebuild": "node --run build:blog-data && node --run build:twoslash-fsmap",
66
"build": "cross-env NODE_NO_WARNINGS=1 next build",
77
"build:blog-data": "cross-env NODE_NO_WARNINGS=1 node ./scripts/blog-data/index.mjs",
88
"build:blog-data:watch": "node --watch --watch-path=pages/en/blog ./scripts/blog-data/index.mjs",
9+
"build:twoslash-fsmap": "node ./scripts/twoslash-fsmap/index.mjs",
910
"cloudflare:build:worker": "OPEN_NEXT_CLOUDFLARE=true opennextjs-cloudflare build",
1011
"cloudflare:deploy": "opennextjs-cloudflare deploy",
1112
"cloudflare:preview": "wrangler dev",
@@ -47,6 +48,7 @@
4748
"@tailwindcss/postcss": "~4.3.0",
4849
"@types/node": "catalog:",
4950
"@types/react": "catalog:",
51+
"@typescript/vfs": "^1.6.4",
5052
"@vcarl/remark-headings": "~0.1.0",
5153
"@vercel/analytics": "~2.0.1",
5254
"@vercel/otel": "~2.1.2",
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
'use strict';
2+
3+
import { readdirSync, readFileSync } from 'node:fs';
4+
import { createRequire } from 'node:module';
5+
import { join, resolve } from 'node:path';
6+
7+
import { createDefaultMapFromNodeModules } from '@typescript/vfs';
8+
import ts from 'typescript';
9+
10+
const require = createRequire(import.meta.url);
11+
12+
/**
13+
* Recursively collects all `.d.ts` files from a directory into the fsMap.
14+
*
15+
* @param {Map<string, string>} fsMap The map to populate
16+
* @param {string} dir The directory to walk
17+
* @param {string} virtualPrefix The virtual path prefix (e.g., "/node_modules/@types/node")
18+
* @param {string} baseDir The base directory for computing relative paths
19+
*/
20+
function collectDtsFiles(fsMap, dir, virtualPrefix, baseDir) {
21+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
22+
a.name.localeCompare(b.name)
23+
);
24+
25+
for (const entry of entries) {
26+
const fullPath = join(dir, entry.name);
27+
28+
if (entry.isDirectory()) {
29+
collectDtsFiles(fsMap, fullPath, virtualPrefix, baseDir);
30+
} else if (entry.isFile() && /\.d\.([^.]+\.)?[cm]?ts$/i.test(entry.name)) {
31+
const relativePath = fullPath.slice(baseDir.length).replace(/\\/g, '/');
32+
const virtualPath = `${virtualPrefix}${relativePath}`;
33+
34+
fsMap.set(virtualPath, readFileSync(fullPath, 'utf8'));
35+
}
36+
}
37+
}
38+
39+
/**
40+
* Generates a virtual filesystem map containing all TypeScript library
41+
* declaration files and `@types/node` declarations needed for Twoslash
42+
* to run without real filesystem access (e.g., on Cloudflare Workers).
43+
*
44+
* @returns {Map<string, string>} A map of virtual paths to file contents
45+
*/
46+
export default function generateTwoslashFsMap() {
47+
// 1. Collect TypeScript lib .d.ts files using @typescript/vfs
48+
// This returns a Map keyed as "/lib.es5.d.ts", "/lib.dom.d.ts", etc.
49+
const fsMap = createDefaultMapFromNodeModules({}, ts);
50+
51+
// 2. Collect @types/node .d.ts files
52+
// These are keyed as "/node_modules/@types/node/index.d.ts", etc.
53+
const typesNodeDir = resolve(
54+
require.resolve('@types/node/package.json'),
55+
'..'
56+
);
57+
58+
collectDtsFiles(
59+
fsMap,
60+
typesNodeDir,
61+
'/node_modules/@types/node',
62+
typesNodeDir
63+
);
64+
65+
return fsMap;
66+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
'use strict';
2+
3+
import { mkdirSync, writeFileSync } from 'node:fs';
4+
5+
import generateTwoslashFsMap from './generate.mjs';
6+
7+
const fsMap = generateTwoslashFsMap();
8+
9+
const outputPath = new URL(
10+
'../../generated/twoslash-fsmap.json',
11+
import.meta.url
12+
);
13+
14+
mkdirSync(new URL('.', outputPath), { recursive: true });
15+
writeFileSync(outputPath, JSON.stringify(Object.fromEntries(fsMap)), 'utf8');

apps/site/turbo.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
]
2323
},
2424
"build": {
25-
"dependsOn": ["build:blog-data", "^build"],
25+
"dependsOn": ["build:blog-data", "build:twoslash-fsmap", "^build"],
2626
"inputs": [
2727
"{app,components,hooks,i18n,layouts,middlewares,pages,providers,types,util}/**/*.{ts,tsx}",
2828
"{app,components,layouts,pages,styles}/**/*.css",
@@ -137,8 +137,12 @@
137137
"ENABLE_EXPERIMENTAL_COREPACK"
138138
]
139139
},
140+
"build:twoslash-fsmap": {
141+
"inputs": ["scripts/twoslash-fsmap/**", "../../pnpm-lock.yaml"],
142+
"outputs": ["generated/twoslash-fsmap.json"]
143+
},
140144
"cloudflare:build:worker": {
141-
"dependsOn": ["build:blog-data"],
145+
"dependsOn": ["build:blog-data", "build:twoslash-fsmap"],
142146
"inputs": [
143147
"{app,components,hooks,i18n,layouts,middlewares,pages,providers,types,util}/**/*.{ts,tsx}",
144148
"{app,components,layouts,pages,styles}/**/*.css",
Lines changed: 60 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { transformerTwoslash } from '@shikijs/twoslash';
1+
import {
2+
createTransformerFactory,
3+
rendererRich,
4+
transformerTwoslash,
5+
} from '@shikijs/twoslash';
26

37
const compose = ({ token, cursor, popup }) => [
48
{
@@ -10,39 +14,60 @@ const compose = ({ token, cursor, popup }) => [
1014
popup,
1115
];
1216

13-
export const twoslash = (options = {}) =>
14-
transformerTwoslash({
15-
langs: ['ts', 'js', 'cjs', 'mjs'],
16-
rendererRich: {
17-
jsdoc: false,
18-
hast: {
19-
hoverToken: { tagName: 'MDXTooltip' },
20-
hoverPopup: { tagName: 'MDXTooltipContent' },
21-
hoverCompose: compose,
22-
23-
queryToken: { tagName: 'MDXTooltip' },
24-
queryPopup: { tagName: 'MDXTooltipContent' },
25-
queryCompose: compose,
26-
27-
errorToken: { tagName: 'MDXTooltip' },
28-
errorPopup: { tagName: 'MDXTooltipContent' },
29-
errorCompose: compose,
30-
31-
completionToken: {
32-
tagName: 'MDXTooltip',
33-
properties: {
34-
open: true,
35-
},
36-
},
37-
completionPopup: {
38-
tagName: 'MDXTooltipContent',
39-
properties: {
40-
align: 'start',
41-
},
42-
},
43-
completionCompose: compose,
17+
const rendererOptions = {
18+
jsdoc: false,
19+
hast: {
20+
hoverToken: { tagName: 'MDXTooltip' },
21+
hoverPopup: { tagName: 'MDXTooltipContent' },
22+
hoverCompose: compose,
23+
24+
queryToken: { tagName: 'MDXTooltip' },
25+
queryPopup: { tagName: 'MDXTooltipContent' },
26+
queryCompose: compose,
27+
28+
errorToken: { tagName: 'MDXTooltip' },
29+
errorPopup: { tagName: 'MDXTooltipContent' },
30+
errorCompose: compose,
31+
32+
completionToken: {
33+
tagName: 'MDXTooltip',
34+
properties: {
35+
open: true,
36+
},
37+
},
38+
completionPopup: {
39+
tagName: 'MDXTooltipContent',
40+
properties: {
41+
align: 'start',
4442
},
4543
},
46-
throws: false,
47-
...options,
48-
});
44+
completionCompose: compose,
45+
},
46+
};
47+
48+
const transformerOptions = {
49+
langs: ['ts', 'js', 'cjs', 'mjs'],
50+
rendererRich: rendererOptions,
51+
throws: false,
52+
};
53+
54+
/**
55+
* Creates the Twoslash Shiki transformer.
56+
*
57+
* When `options.twoslasher` is provided, uses `createTransformerFactory`
58+
* directly to avoid importing the default Node.js-dependent twoslasher from
59+
* `twoslash`. This is needed for environments like Cloudflare Workers where
60+
* the filesystem-backed default twoslasher cannot be used.
61+
*
62+
* @param {import('@shikijs/twoslash').TransformerTwoslashIndexOptions} [options]
63+
*/
64+
export const twoslash = (options = {}) => {
65+
if (options.twoslasher) {
66+
return createTransformerFactory(
67+
options.twoslasher,
68+
rendererRich(rendererOptions)
69+
)({ ...transformerOptions, ...options });
70+
}
71+
72+
return transformerTwoslash({ ...transformerOptions, ...options });
73+
};

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)