Skip to content

Commit 841a898

Browse files
feat: enable Twoslash on Cloudflare
1 parent a26c508 commit 841a898

7 files changed

Lines changed: 199 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

apps/site/mdx/plugins.mjs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,37 @@ import remarkTableTitles from '../util/table';
1515
// Reference: https://github.com/nodejs/nodejs.org/pull/7896#issuecomment-3009480615
1616
const OPEN_NEXT_CLOUDFLARE = 'Cloudflare' in global;
1717

18+
/**
19+
* Creates a Twoslash instance backed by a virtual filesystem for environments
20+
* without real filesystem access (e.g. Cloudflare Workers).
21+
*
22+
* Uses a pre-built JSON map of TypeScript lib declarations and @types/node
23+
* generated at build time by `scripts/twoslash-fsmap/index.mjs`.
24+
*/
25+
async function createVfsTwoslasher() {
26+
const [{ createTwoslasher }, ts, fsMapJson] = await Promise.all([
27+
import('twoslash/core'),
28+
import('typescript').then(m => m.default),
29+
import('../generated/twoslash-fsmap.json', { with: { type: 'json' } }).then(
30+
m => m.default
31+
),
32+
]);
33+
34+
const fsMap = new Map(Object.entries(fsMapJson));
35+
36+
return createTwoslasher({
37+
fsMap,
38+
tsModule: ts,
39+
vfsRoot: '/',
40+
compilerOptions: {
41+
moduleResolution: ts.ModuleResolutionKind.Bundler,
42+
// Explicitly include @types/node so that the VFS resolves Node.js
43+
// globals and `node:*` module imports from the bundled declarations.
44+
types: ['node'],
45+
},
46+
});
47+
}
48+
1849
// Shiki is created out here to avoid an async rehype plugin
1950
const singletonShiki = await rehypeShikiji({
2051
// We use the faster WASM engine on the server instead of the web-optimized version.
@@ -25,8 +56,15 @@ const singletonShiki = await rehypeShikiji({
2556
// for security reasons.
2657
wasm: !OPEN_NEXT_CLOUDFLARE,
2758

28-
// TODO(@avivkeller): Find a way to enable Twoslash w/ a VFS on Cloudflare
29-
twoslash: !OPEN_NEXT_CLOUDFLARE,
59+
twoslash: true,
60+
61+
// On Cloudflare Workers, the default filesystem-backed Twoslash cannot work
62+
// because there is no real filesystem. Instead, we provide a custom twoslasher
63+
// backed by an in-memory VFS pre-populated at build time with TypeScript
64+
// lib declarations and @types/node.
65+
twoslashOptions: OPEN_NEXT_CLOUDFLARE
66+
? { twoslasher: await createVfsTwoslasher() }
67+
: undefined,
3068
});
3169

3270
/**

apps/site/package.json

Lines changed: 2 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",
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
'use strict';
2+
3+
import { readdirSync, readFileSync } from 'node:fs';
4+
import { createRequire } from 'node:module';
5+
import { dirname, join, resolve } from 'node:path';
6+
7+
const require = createRequire(import.meta.url);
8+
9+
/**
10+
* Recursively collects all `.d.ts` files from a directory into the fsMap.
11+
*
12+
* @param {Record<string, string>} fsMap The map to populate
13+
* @param {string} dir The directory to walk
14+
* @param {string} virtualPrefix The virtual path prefix (e.g., "/node_modules/@types/node")
15+
* @param {string} baseDir The base directory for computing relative paths
16+
*/
17+
function collectDtsFiles(fsMap, dir, virtualPrefix, baseDir) {
18+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) =>
19+
a.name.localeCompare(b.name)
20+
);
21+
22+
for (const entry of entries) {
23+
const fullPath = join(dir, entry.name);
24+
25+
if (entry.isDirectory()) {
26+
collectDtsFiles(fsMap, fullPath, virtualPrefix, baseDir);
27+
} else if (entry.isFile() && /\.d\.([^.]+\.)?[cm]?ts$/i.test(entry.name)) {
28+
const relativePath = fullPath.slice(baseDir.length).replace(/\\/g, '/');
29+
const virtualPath = `${virtualPrefix}${relativePath}`;
30+
31+
fsMap[virtualPath] = readFileSync(fullPath, 'utf8');
32+
}
33+
}
34+
}
35+
36+
/**
37+
* Generates a virtual filesystem map containing all TypeScript library
38+
* declaration files and `@types/node` declarations needed for Twoslash
39+
* to run without real filesystem access (e.g., on Cloudflare Workers).
40+
*
41+
* @returns {Record<string, string>} A map of virtual paths to file contents
42+
*/
43+
export default function generateTwoslashFsMap() {
44+
const fsMap = {};
45+
46+
// 1. Collect TypeScript lib .d.ts files
47+
// These are keyed as "/lib.es5.d.ts", "/lib.dom.d.ts", etc.
48+
// (matching the convention used by @typescript/vfs)
49+
const tsLibDir = dirname(require.resolve('typescript/lib/lib.d.ts'));
50+
const tsLibFiles = readdirSync(tsLibDir)
51+
.filter(f => f.startsWith('lib.') && /\.d\.([^.]+\.)?[cm]?ts$/i.test(f))
52+
.sort();
53+
54+
for (const file of tsLibFiles) {
55+
fsMap[`/${file}`] = readFileSync(join(tsLibDir, file), 'utf8');
56+
}
57+
58+
// 2. Collect @types/node .d.ts files
59+
// These are keyed as "/node_modules/@types/node/index.d.ts", etc.
60+
const typesNodeDir = resolve(
61+
require.resolve('@types/node/package.json'),
62+
'..'
63+
);
64+
65+
collectDtsFiles(
66+
fsMap,
67+
typesNodeDir,
68+
'/node_modules/@types/node',
69+
typesNodeDir
70+
);
71+
72+
return fsMap;
73+
}
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(fsMap), 'utf8');

apps/site/turbo.json

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@
2424
]
2525
},
2626
"build": {
27-
"dependsOn": ["build:blog-data", "^build"],
27+
"dependsOn": ["build:blog-data", "build:twoslash-fsmap", "^build"],
2828
"inputs": [
2929
"{app,components,hooks,i18n,layouts,middlewares,pages,providers,types,util}/**/*.{ts,tsx}",
3030
"{app,components,layouts,pages,styles}/**/*.css",
@@ -145,8 +145,12 @@
145145
"ENABLE_EXPERIMENTAL_COREPACK"
146146
]
147147
},
148+
"build:twoslash-fsmap": {
149+
"inputs": ["scripts/twoslash-fsmap/**", "../../pnpm-lock.yaml"],
150+
"outputs": ["generated/twoslash-fsmap.json"]
151+
},
148152
"cloudflare:build:worker": {
149-
"dependsOn": ["build:blog-data"],
153+
"dependsOn": ["build:blog-data", "build:twoslash-fsmap"],
150154
"inputs": [
151155
"{app,components,hooks,i18n,layouts,middlewares,pages,providers,types,util}/**/*.{ts,tsx}",
152156
"{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+
};

0 commit comments

Comments
 (0)