1import MagicString from "magic-string";
2import path from "node:path";
3import type {
4 Plugin,
5 PluginContext,
6 RenderedChunk,
7 RolldownMagicString,
8 SourceMap,
9} from "rolldown";
10import { createPlugin } from "../factory.ts";
11import { sanitizePath, toPosixPath } from "../utils.ts";
12
13export const MODULE_RULES = [
14 { type: "CompiledWasm", pattern: /\.wasm(\?module)?$/ },
15 { type: "Data", pattern: /\.bin$/ },
16 { type: "Text", pattern: /\.(txt|html|sql)$/ },
17] as const;
18
19const MODULE_REFERENCE_PATTERN = `__CLOUDFLARE_MODULE__(${MODULE_RULES.map((rule) => rule.type).join("|")})__(.*?)__CLOUDFLARE_MODULE__`;
20export const MODULE_REFERENCE_REGEX = new RegExp(MODULE_REFERENCE_PATTERN);
21const MODULE_REFERENCE_GLOBAL_REGEX = new RegExp(MODULE_REFERENCE_PATTERN, "g");
22
23export const additionalModulesPlugin = createPlugin(
24 "additional-modules",
25 () => {
26 const additionalModulePaths = new Set<string>();
27 return {
28 vite: {
29 enforce: "pre",
30 hotUpdate(options) {
31 if (additionalModulePaths.has(options.file)) {
32 void options.server.restart();
33 return [];
34 }
35 },
36 },
37 shared: {
38 resolveId: {
39 filter: { id: MODULE_RULES.map((rule) => rule.pattern) },
40 async handler(source, importer, options) {
41 const resolved = await this.resolve(source, importer, options);
42 if (!resolved) {
43 return;
44 }
45
46 const rule = MODULE_RULES.find((rule) =>
47 rule.pattern.test(resolved.id),
48 );
49 if (!rule) {
50 return resolved;
51 }
52
53 const filePath = sanitizePath(resolved.id);
54 additionalModulePaths.add(filePath);
55
56 return {
57 id: moduleReferenceId(rule.type, filePath),
58 external: true,
59 };
60 },
61 },
62 renderChunk: {
63 filter: { code: { include: MODULE_REFERENCE_REGEX } },
64 handler: withMagicString(async function (code, chunk, magicString) {
65 const matches = code.matchAll(MODULE_REFERENCE_GLOBAL_REGEX);
66 for (const match of matches) {
67 const [full, , id] = match;
68 const source = await this.fs.readFile(id);
69 const referenceId = this.emitFile({
70 type: "asset",
71 name: path.basename(id),
72 source,
73 });
74 const fileName = this.getFileName(referenceId);
75 const relativePath = toPosixPath(
76 path.relative(path.dirname(chunk.fileName), fileName),
77 );
78 const importPath = relativePath.startsWith(".")
79 ? relativePath
80 : `./${relativePath}`;
81 magicString.update(
82 match.index,
83 match.index + full.length,
84 importPath,
85 );
86 }
87 }),
88 },
89 },
90 };
91 },
92);
93
94function moduleReferenceId(type: "CompiledWasm" | "Data" | "Text", id: string) {
95 return `__CLOUDFLARE_MODULE__${type}__${id}__CLOUDFLARE_MODULE__` as const;
96}
97
98type PluginHandler<T extends keyof Plugin> = Plugin[T] extends infer T
99 ? T extends (...args: any) => any
100 ? T
101 : never
102 : never;
103
104/**
105 * Returns a `renderChunk` handler that transforms the chunk using a magic string.
106 * Uses Rolldown's native magic string if available, or the `magic-string` library otherwise.
107 */
108function withMagicString(
109 renderChunk: (
110 this: PluginContext,
111 code: string,
112 chunk: RenderedChunk,
113 magicString: MagicString | RolldownMagicString,
114 ) => Promise<void>,
115): PluginHandler<"renderChunk"> {
116 return async function (code, chunk, outputOptions, meta) {
117 const magicString = meta.magicString ?? new MagicString(code);
118 await renderChunk.call(this, code, chunk, magicString);
119 if (
120 "isRolldownMagicString" in magicString &&
121 magicString.isRolldownMagicString
122 ) {
123 return magicString;
124 }
125 return {
126 code: magicString.toString(),
127 map: outputOptions.sourcemap
128 ? (magicString.generateMap({ hires: "boundary" }) as SourceMap)
129 : null,
130 };
131 };
132}