Compare commits

...

1 Commits

Author SHA1 Message Date
Jarred Sumner
36100ed5c2 watchedFiles starting point 2025-02-24 15:47:47 -08:00
7 changed files with 231 additions and 9 deletions

View File

@@ -754,6 +754,13 @@ pub const JSBundler = struct {
success: struct {
source_code: []const u8 = "",
loader: options.Loader = .file,
watched_files: bun.StringSet = bun.StringSet.init(bun.default_allocator),
pub fn deinit(this: *const @This()) void {
bun.default_allocator.free(this.source_code);
var watched_files = this.watched_files;
watched_files.deinit();
}
},
pending,
no_match,
@@ -762,8 +769,8 @@ pub const JSBundler = struct {
pub fn deinit(this: *Value) void {
switch (this.*) {
.success => |success| {
bun.default_allocator.free(success.source_code);
.success => |*success| {
success.deinit();
},
.err => |*err| {
err.deinit(bun.default_allocator);
@@ -842,6 +849,7 @@ pub const JSBundler = struct {
_: *anyopaque,
source_code_value: JSValue,
loader_as_int: JSValue,
watched_files_value: JSValue,
) void {
JSC.markBinding(@src());
if (source_code_value.isEmptyOrUndefinedOrNull() or loader_as_int.isEmptyOrUndefinedOrNull()) {
@@ -858,10 +866,28 @@ pub const JSBundler = struct {
const source_code = JSC.Node.StringOrBuffer.fromJSToOwnedSlice(this.bv2.plugins.?.globalObject(), source_code_value, bun.default_allocator) catch
// TODO:
@panic("Unexpected: source_code is not a string");
var watched_files = bun.StringSet.init(bun.default_allocator);
if (!watched_files_value.isEmptyOrUndefinedOrNull()) {
const globalObject = this.bv2.plugins.?.globalObject();
if (watched_files_value.isArray() and watched_files_value.getLength(globalObject) > 0) {
var iter = watched_files_value.arrayIterator(globalObject);
while (iter.next()) |value| {
// TODO: handle this error
var str = value.toBunString2(globalObject) catch bun.outOfMemory();
defer str.deref();
const slice = str.toUTF8WithoutRef(bun.default_allocator);
defer slice.deinit();
watched_files.insert(slice.slice()) catch bun.outOfMemory();
}
}
}
this.value = .{
.success = .{
.loader = options.Loader.fromAPI(loader),
.source_code = source_code,
.watched_files = watched_files,
},
};
}

View File

@@ -45,7 +45,7 @@ extern "C" void OnBeforeParseResult__reset(OnBeforeParseResult* result);
/// These are callbacks defined in Zig and to be run after their associated JS version is run
extern "C" void JSBundlerPlugin__addError(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue);
extern "C" void JSBundlerPlugin__onLoadAsync(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue);
extern "C" void JSBundlerPlugin__onLoadAsync(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue);
extern "C" void JSBundlerPlugin__onResolveAsync(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue);
extern "C" void JSBundlerPlugin__onVirtualModulePlugin(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue);
extern "C" JSC::EncodedJSValue JSBundlerPlugin__onDefer(void*, JSC::JSGlobalObject*);
@@ -419,7 +419,8 @@ JSC_DEFINE_HOST_FUNCTION(jsBundlerPluginFunction_onLoadAsync, (JSC::JSGlobalObje
UNWRAP_BUNDLER_PLUGIN(callFrame),
thisObject->plugin.config,
JSValue::encode(callFrame->argument(1)),
JSValue::encode(callFrame->argument(2)));
JSValue::encode(callFrame->argument(2)),
JSValue::encode(callFrame->argument(3)));
}
return JSC::JSValue::encode(JSC::jsUndefined());

View File

@@ -9,7 +9,7 @@
#include <JavaScriptCore/Yarr.h>
typedef void (*JSBundlerPluginAddErrorCallback)(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue);
typedef void (*JSBundlerPluginOnLoadAsyncCallback)(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue);
typedef void (*JSBundlerPluginOnLoadAsyncCallback)(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue);
typedef void (*JSBundlerPluginOnResolveAsyncCallback)(void*, void*, JSC::EncodedJSValue, JSC::EncodedJSValue, JSC::EncodedJSValue);
typedef void (*JSBundlerPluginNativeOnBeforeParseCallback)(const OnBeforeParseArguments*, OnBeforeParseResult*);

View File

@@ -2161,6 +2161,9 @@ pub const BundleV2 = struct {
this.decrementScanCounter();
},
.success => |code| {
var watched_files = code.watched_files;
defer watched_files.deinit();
const should_copy_for_bundling = load.parse_task.defer_copy_for_bundling and code.loader.shouldCopyForBundling();
if (should_copy_for_bundling) {
const source_index = load.source_index;
@@ -2181,7 +2184,47 @@ pub const BundleV2 = struct {
this.graph.pool.schedule(parse_task);
if (this.bun_watcher) |watcher| add_watchers: {
// TODO: support explicit watchFiles array
// Check if we have watched files specified
// First, register watched files as import records in the AST
// This ensures they'll be part of the dependency graph for HMR
var import_records = &this.graph.ast.items(.import_records)[load.source_index.get()];
for (watched_files.keys()) |watched_file| {
// Add an import record for each watched file
import_records.push(this.graph.allocator, .{
.source_index = load.source_index,
.path = Fs.Path.init(this.graph.allocator.dupeZ(u8, watched_file) catch bun.outOfMemory()),
.kind = .stmt,
.is_dynamic_import = false,
.is_watched_file = true, // Mark this as a special watched file
}) catch bun.outOfMemory();
debug("Added watched file as import: {s}", .{watched_file});
// Watch the file
const wfd = if (bun.Watcher.requires_file_descriptors)
switch (bun.sys.open(
&(std.posix.toPosixPath(watched_file) catch continue),
bun.C.O_EVTONLY,
0,
)) {
.result => |wfd| wfd,
.err => continue,
}
else
bun.invalid_fd;
_ = watcher.appendFile(
wfd,
watched_file,
bun.Watcher.getHash(watched_file),
code.loader,
bun.invalid_fd,
null,
true,
);
}
// Always watch the primary file
const fd = if (bun.Watcher.requires_file_descriptors)
switch (bun.sys.open(
&(std.posix.toPosixPath(load.path) catch break :add_watchers),

View File

@@ -162,6 +162,11 @@ pub const ImportRecord = struct {
/// Used to prevent running resolve plugins multiple times for the same path
print_namespace_in_path: bool = false,
/// If true, this import record represents a file from the `watchedFiles` array
/// returned by a plugin. It doesn't contribute to the actual module code but
/// ensures the file is tracked for HMR.
is_watched_file: bool = false,
wrap_with_to_esm: bool = false,
wrap_with_to_commonjs: bool = false,

View File

@@ -9,6 +9,7 @@ interface BundlerPlugin {
internalID,
sourceCode: string | Uint8Array | ArrayBuffer | DataView | null,
loaderKey: number | null,
watchedFiles?: string[] | null,
): void;
/** Binding to `JSBundlerPlugin__onResolveAsync` */
onResolveAsync(internalID, a, b, c): void;
@@ -472,7 +473,7 @@ export function runOnLoadPlugins(
continue;
}
var { contents, loader = defaultLoader } = result as any;
var { contents, loader = defaultLoader, watchedFiles } = result as any;
if ((loader as any) === "object") {
if (!("exports" in result)) {
throw new TypeError('onLoad plugin returning loader: "object" must have "exports" property');
@@ -493,17 +494,30 @@ export function runOnLoadPlugins(
throw new TypeError('onLoad plugins must return an object with "loader" as a string');
}
// Validate watchedFiles if provided
if (watchedFiles !== undefined) {
if (!Array.isArray(watchedFiles)) {
throw new TypeError('onLoad plugins "watchedFiles" field must be an array of strings');
}
for (let i = 0; i < watchedFiles.length; i++) {
if (typeof watchedFiles[i] !== 'string') {
throw new TypeError('onLoad plugins "watchedFiles" field must contain only strings');
}
}
}
const chosenLoader = LOADERS_MAP[loader];
if (chosenLoader === undefined) {
throw new TypeError(`Loader ${loader} is not supported.`);
}
this.onLoadAsync(internalID, contents as any, chosenLoader);
this.onLoadAsync(internalID, contents as any, chosenLoader, watchedFiles);
return null;
}
}
this.onLoadAsync(internalID, null, null);
this.onLoadAsync(internalID, null, null, null);
return null;
})(internalID, path, namespace, isServerSide, loaderName, generateDefer);

View File

@@ -0,0 +1,133 @@
// Tests for watchedFiles feature in dev plugins
import { devTest, minimalFramework } from "../dev-server-harness";
devTest("onLoad with watchedFiles for non-JS file", {
framework: minimalFramework,
pluginFile: `
import * as fs from 'fs';
import * as path from 'path';
export default [
{
name: 'watchedFiles-plugin',
setup(build) {
let fileCounter = 0;
build.onLoad({ filter: /\\.counter\\.js$/ }, (args) => {
// Read the current value from the data file
try {
// Data file is just a plain text file with a number
const dataPath = path.join(path.dirname(args.path), "counter.txt");
const absoluteDataPath = path.resolve(dataPath);
fileCounter++;
// Log to make debugging easier
console.log("[PLUGIN] Loading counter file. Count:", fileCounter);
try {
const content = fs.readFileSync(absoluteDataPath, 'utf8');
const counterValue = content.trim();
console.log("[PLUGIN] Read counter value:", counterValue);
// We're returning a module that exports both the counter value
// and the number of times this plugin has been invoked
return {
contents: \`console.log("[COUNTER MODULE] Loading with value: \${counterValue}, plugin invocation #\${fileCounter}");
export default {
counterValue: "\${counterValue}",
loadCount: \${fileCounter}
};\`,
loader: 'js',
// Add the data file to the watchedFiles array
watchedFiles: [absoluteDataPath]
};
} catch (err) {
console.error("[PLUGIN] Error reading counter.txt:", err);
return {
contents: \`export default { counterValue: "error", loadCount: \${fileCounter} };\`,
loader: 'js'
};
}
} catch (e) {
console.error("[PLUGIN] Error in plugin:", e);
return {
contents: \`export default { counterValue: "error", loadCount: 0 };\`,
loader: 'js'
};
}
});
},
}
];
`,
files: {
// The main file that will be directly loaded by the module graph
"counter.counter.js": `
// This content doesn't matter since our plugin intercepts it
console.log('This should not be loaded');
`,
// This is a plain text file containing only a number
// It's NOT part of the module graph, it's only watched via watchedFiles
"counter.txt": "1",
"routes/index.ts": `
import counter from '../counter.counter.js';
export default function (req, meta) {
return new Response(\`Counter value: \${counter.counterValue} (Load count: \${counter.loadCount})\`);
}
`,
},
async test(dev) {
// Initial load should show counter value 1
const response1 = await dev.fetch("/");
await response1.expect.toMatch(/Counter value: 1/);
// We need to ensure the watcher has time to fully initialize
await Bun.sleep(1000);
// Modify the counter.txt file (which is in the watchedFiles array)
await dev.write("counter.txt", "2", { dedent: false });
// Give the watcher time to detect changes
await dev.waitForHotReload();
await Bun.sleep(300);
// After modifying the watched file, the counter should update
// and the load count should increase
const response2 = await dev.fetch("/");
await response2.expect.toMatch(/Counter value: 2/);
// The loadCount should be 2 because the plugin ran again
await response2.expect.toMatch(/Load count: 2/);
// Modify counter.txt again
await dev.write("counter.txt", "3", { dedent: false });
// Wait for the HMR
await dev.waitForHotReload();
await Bun.sleep(300);
// Value should be updated and loadCount increased again
const response3 = await dev.fetch("/");
await response3.expect.toMatch(/Counter value: 3/);
await response3.expect.toMatch(/Load count: 3/);
// Modify the main JS file as well to ensure both file
// types trigger reloads
await dev.write("counter.counter.js", `
// Different content, still intercepted
console.log('Still intercepted');
`);
// Wait for the HMR
await dev.waitForHotReload();
await Bun.sleep(300);
// Counter value should still be 3, and loadCount increases again
const response4 = await dev.fetch("/");
await response4.expect.toMatch(/Counter value: 3/);
await response4.expect.toMatch(/Load count: 4/);
},
// Give the test more time to complete
timeoutMultiplier: 3
});