From 1d138057cb861fe540cfe5ef49905225cee40ae8 Mon Sep 17 00:00:00 2001 From: Zhongwei Yao Date: Thu, 6 Apr 2023 14:01:49 -0700 Subject: [PATCH] Add last modify field "mtime" for FileBlob (#1431) (#2491) * Add lastModified field for FileBlob (#1431) lastModified value is epoch timestamp in millisecond unit. * update according to review comment. --- src/bun.js/bindings/ZigGeneratedClasses.cpp | 16 +++ src/bun.js/bindings/generated_classes.zig | 4 + src/bun.js/webcore/blob.zig | 118 ++++++++++++++------ src/bun.js/webcore/response.classes.ts | 4 + test/js/bun/io/bun-write.test.js | 18 +++ test/js/web/fetch/fetch.test.ts | 1 + 6 files changed, 128 insertions(+), 33 deletions(-) diff --git a/src/bun.js/bindings/ZigGeneratedClasses.cpp b/src/bun.js/bindings/ZigGeneratedClasses.cpp index a16c8d5ad9b95..cd263dce4e3a0 100644 --- a/src/bun.js/bindings/ZigGeneratedClasses.cpp +++ b/src/bun.js/bindings/ZigGeneratedClasses.cpp @@ -109,6 +109,9 @@ JSC_DECLARE_HOST_FUNCTION(BlobPrototype__formDataCallback); extern "C" EncodedJSValue BlobPrototype__getJSON(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject, JSC::CallFrame* callFrame); JSC_DECLARE_HOST_FUNCTION(BlobPrototype__jsonCallback); +extern "C" JSC::EncodedJSValue BlobPrototype__getLastModified(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject); +JSC_DECLARE_CUSTOM_GETTER(BlobPrototype__lastModifiedGetterWrap); + extern "C" JSC::EncodedJSValue BlobPrototype__getSize(void* ptr, JSC::JSGlobalObject* lexicalGlobalObject); JSC_DECLARE_CUSTOM_GETTER(BlobPrototype__sizeGetterWrap); @@ -133,6 +136,7 @@ static const HashTableValue JSBlobPrototypeTableValues[] = { { "arrayBuffer"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, BlobPrototype__arrayBufferCallback, 0 } }, { "formData"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, BlobPrototype__formDataCallback, 0 } }, { "json"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, BlobPrototype__jsonCallback, 0 } }, + { "lastModified"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, BlobPrototype__lastModifiedGetterWrap, 0 } }, { "size"_s, static_cast(JSC::PropertyAttribute::ReadOnly | JSC::PropertyAttribute::CustomAccessor | JSC::PropertyAttribute::DOMAttribute | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::GetterSetterType, BlobPrototype__sizeGetterWrap, 0 } }, { "slice"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, BlobPrototype__sliceCallback, 2 } }, { "stream"_s, static_cast(JSC::PropertyAttribute::Function | PropertyAttribute::DontDelete), NoIntrinsic, { HashTableValue::NativeFunctionType, BlobPrototype__streamCallback, 1 } }, @@ -203,6 +207,18 @@ JSC_DEFINE_HOST_FUNCTION(BlobPrototype__jsonCallback, (JSGlobalObject * lexicalG return BlobPrototype__getJSON(thisObject->wrapped(), lexicalGlobalObject, callFrame); } +JSC_DEFINE_CUSTOM_GETTER(BlobPrototype__lastModifiedGetterWrap, (JSGlobalObject * lexicalGlobalObject, EncodedJSValue thisValue, PropertyName attributeName)) +{ + auto& vm = lexicalGlobalObject->vm(); + Zig::GlobalObject* globalObject = reinterpret_cast(lexicalGlobalObject); + auto throwScope = DECLARE_THROW_SCOPE(vm); + JSBlob* thisObject = jsCast(JSValue::decode(thisValue)); + JSC::EnsureStillAliveScope thisArg = JSC::EnsureStillAliveScope(thisObject); + JSC::EncodedJSValue result = BlobPrototype__getLastModified(thisObject->wrapped(), globalObject); + RETURN_IF_EXCEPTION(throwScope, {}); + RELEASE_AND_RETURN(throwScope, result); +} + JSC_DEFINE_CUSTOM_GETTER(BlobPrototype__sizeGetterWrap, (JSGlobalObject * lexicalGlobalObject, EncodedJSValue thisValue, PropertyName attributeName)) { auto& vm = lexicalGlobalObject->vm(); diff --git a/src/bun.js/bindings/generated_classes.zig b/src/bun.js/bindings/generated_classes.zig index ed99530044475..6602309ab00b3 100644 --- a/src/bun.js/bindings/generated_classes.zig +++ b/src/bun.js/bindings/generated_classes.zig @@ -90,6 +90,9 @@ pub const JSBlob = struct { @compileLog("Expected Blob.getFormData to be a callback but received " ++ @typeName(@TypeOf(Blob.getFormData))); if (@TypeOf(Blob.getJSON) != CallbackType) @compileLog("Expected Blob.getJSON to be a callback but received " ++ @typeName(@TypeOf(Blob.getJSON))); + if (@TypeOf(Blob.getLastModified) != GetterType) + @compileLog("Expected Blob.getLastModified to be a getter"); + if (@TypeOf(Blob.getSize) != GetterType) @compileLog("Expected Blob.getSize to be a getter"); @@ -110,6 +113,7 @@ pub const JSBlob = struct { @export(Blob.getArrayBuffer, .{ .name = "BlobPrototype__getArrayBuffer" }); @export(Blob.getFormData, .{ .name = "BlobPrototype__getFormData" }); @export(Blob.getJSON, .{ .name = "BlobPrototype__getJSON" }); + @export(Blob.getLastModified, .{ .name = "BlobPrototype__getLastModified" }); @export(Blob.getSize, .{ .name = "BlobPrototype__getSize" }); @export(Blob.getSlice, .{ .name = "BlobPrototype__getSlice" }); @export(Blob.getStream, .{ .name = "BlobPrototype__getStream" }); diff --git a/src/bun.js/webcore/blob.zig b/src/bun.js/webcore/blob.zig index 284351347a11d..030b77a2c481e 100644 --- a/src/bun.js/webcore/blob.zig +++ b/src/bun.js/webcore/blob.zig @@ -99,6 +99,11 @@ pub const Blob = struct { pub const SizeType = u52; pub const max_size = std.math.maxInt(SizeType); + /// According to https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date, + /// maximum Date in JavaScript is less than Number.MAX_SAFE_INTEGER (u52). + pub const JSTimeType = u52; + pub const init_timestamp = std.math.maxInt(JSTimeType); + pub fn getFormDataEncoding(this: *Blob) ?*bun.FormData.AsyncFormData { var content_type_slice: ZigString.Slice = this.getContentType() orelse return null; defer content_type_slice.deinit(); @@ -572,9 +577,18 @@ pub const Blob = struct { return null; } - if (path_or_blob == .blob and path_or_blob.blob.store == null) { - exception.* = JSC.toInvalidArguments("Blob is detached", .{}, ctx).asObjectRef(); - return null; + if (path_or_blob == .blob) { + if (path_or_blob.blob.store == null) { + exception.* = JSC.toInvalidArguments("Blob is detached", .{}, ctx).asObjectRef(); + return null; + } else { + // TODO only reset last_modified on success pathes instead of + // resetting last_modified at the beginning for better performance. + if (path_or_blob.blob.store.?.data == .file) { + // reset last_modified to force getLastModified() to reload after writing. + path_or_blob.blob.store.?.data.file.last_modified = init_timestamp; + } + } } var needs_async = false; @@ -1439,7 +1453,7 @@ pub const Blob = struct { } } - fn resolveSize(this: *ReadFile, fd: bun.FileDescriptor) void { + fn resolveSizeAndLastModified(this: *ReadFile, fd: bun.FileDescriptor) void { const stat: std.os.Stat = switch (JSC.Node.Syscall.fstat(fd)) { .result => |result| result, .err => |err| { @@ -1448,6 +1462,13 @@ pub const Blob = struct { return; }, }; + + if (this.store) |store| { + if (store.data == .file) { + store.data.file.last_modified = toJSTime(stat.mtime().tv_sec, stat.mtime().tv_nsec); + } + } + if (std.os.S.ISDIR(stat.mode)) { this.errno = error.EISDIR; this.system_error = JSC.SystemError{ @@ -1483,7 +1504,7 @@ pub const Blob = struct { return; } - this.resolveSize(fd); + this.resolveSizeAndLastModified(fd); if (this.errno != null) return this.onFinish(); @@ -2170,6 +2191,8 @@ pub const Blob = struct { mode: JSC.Node.Mode = 0, seekable: ?bool = null, max_size: SizeType = Blob.max_size, + // milliseconds since ECMAScript epoch + last_modified: JSTimeType = init_timestamp, pub fn isSeekable(this: *const FileStore) ?bool { if (this.seekable) |seekable| { @@ -2513,6 +2536,23 @@ pub const Blob = struct { return ZigString.Empty.toValue(globalThis); } + pub fn getLastModified( + this: *Blob, + _: *JSC.JSGlobalObject, + ) callconv(.C) JSValue { + if (this.store) |store| { + if (store.data == .file) { + // last_modified can be already set during read. + if (store.data.file.last_modified == init_timestamp) { + resolveFileStat(store); + } + return JSValue.jsNumber(store.data.file.last_modified); + } + } + + return JSValue.jsNumber(init_timestamp); + } + pub fn getSize(this: *Blob, _: *JSC.JSGlobalObject) callconv(.C) JSValue { if (this.size == Blob.max_size) { this.resolveSize(); @@ -2544,34 +2584,7 @@ pub const Blob = struct { return; } else if (store.data == .file) { if (store.data.file.seekable == null) { - if (store.data.file.pathlike == .path) { - var buffer: [bun.MAX_PATH_BYTES]u8 = undefined; - switch (JSC.Node.Syscall.stat(store.data.file.pathlike.path.sliceZ(&buffer))) { - .result => |stat| { - store.data.file.max_size = if (std.os.S.ISREG(stat.mode) or stat.size > 0) - @truncate(SizeType, @intCast(u64, @max(stat.size, 0))) - else - Blob.max_size; - store.data.file.mode = stat.mode; - store.data.file.seekable = std.os.S.ISREG(stat.mode); - }, - // the file may not exist yet. Thats's okay. - else => {}, - } - } else if (store.data.file.pathlike == .fd) { - switch (JSC.Node.Syscall.fstat(store.data.file.pathlike.fd)) { - .result => |stat| { - store.data.file.max_size = if (std.os.S.ISREG(stat.mode) or stat.size > 0) - @truncate(SizeType, @intCast(u64, @max(stat.size, 0))) - else - Blob.max_size; - store.data.file.mode = stat.mode; - store.data.file.seekable = std.os.S.ISREG(stat.mode); - }, - // the file may not exist yet. Thats's okay. - else => {}, - } - } + resolveFileStat(store); } if (store.data.file.seekable != null and store.data.file.max_size != Blob.max_size) { @@ -2590,6 +2603,45 @@ pub const Blob = struct { } } + fn toJSTime(sec: isize, nsec: isize) JSTimeType { + const millisec = @intCast(u64, @divTrunc(nsec, std.time.ns_per_ms)); + return @truncate(JSTimeType, @intCast(u64, sec * std.time.ms_per_s) + millisec); + } + + /// resolve file stat like size, last_modified + fn resolveFileStat(store: *Store) void { + if (store.data.file.pathlike == .path) { + var buffer: [bun.MAX_PATH_BYTES]u8 = undefined; + switch (JSC.Node.Syscall.stat(store.data.file.pathlike.path.sliceZ(&buffer))) { + .result => |stat| { + store.data.file.max_size = if (std.os.S.ISREG(stat.mode) or stat.size > 0) + @truncate(SizeType, @intCast(u64, @max(stat.size, 0))) + else + Blob.max_size; + store.data.file.mode = stat.mode; + store.data.file.seekable = std.os.S.ISREG(stat.mode); + store.data.file.last_modified = toJSTime(stat.mtime().tv_sec, stat.mtime().tv_nsec); + }, + // the file may not exist yet. Thats's okay. + else => {}, + } + } else if (store.data.file.pathlike == .fd) { + switch (JSC.Node.Syscall.fstat(store.data.file.pathlike.fd)) { + .result => |stat| { + store.data.file.max_size = if (std.os.S.ISREG(stat.mode) or stat.size > 0) + @truncate(SizeType, @intCast(u64, @max(stat.size, 0))) + else + Blob.max_size; + store.data.file.mode = stat.mode; + store.data.file.seekable = std.os.S.ISREG(stat.mode); + store.data.file.last_modified = toJSTime(stat.mtime().tv_sec, stat.mtime().tv_nsec); + }, + // the file may not exist yet. Thats's okay. + else => {}, + } + } + } + pub fn constructor( globalThis: *JSC.JSGlobalObject, callframe: *JSC.CallFrame, diff --git a/src/bun.js/webcore/response.classes.ts b/src/bun.js/webcore/response.classes.ts index 4fdce1c0cb22c..5f3ba2e4aa588 100644 --- a/src/bun.js/webcore/response.classes.ts +++ b/src/bun.js/webcore/response.classes.ts @@ -141,6 +141,10 @@ export default [ getter: "getSize", }, + lastModified: { + getter: "getLastModified", + }, + writer: { fn: "getWriter", length: 1, diff --git a/test/js/bun/io/bun-write.test.js b/test/js/bun/io/bun-write.test.js index c3b9b187bbd4f..ab8063f161385 100644 --- a/test/js/bun/io/bun-write.test.js +++ b/test/js/bun/io/bun-write.test.js @@ -2,6 +2,7 @@ import fs from "fs"; import { it, expect, describe } from "bun:test"; import path from "path"; import { gcTick, withoutAggressiveGC } from "harness"; +import { tmpdir } from "os"; it("Bun.write blob", async () => { await Bun.write(Bun.file("/tmp/response-file.test.txt"), Bun.file(path.join(import.meta.dir, "fetch.js.txt"))); @@ -147,6 +148,23 @@ it("Bun.file empty file", async () => { await gcTick(); }); +it("Bun.file lastModified update", async () => { + const file = Bun.file(tmpdir() + "/bun.test.lastModified.txt"); + await gcTick(); + // setup + await Bun.write(file, "test text."); + const lastModified0 = file.lastModified; + + // sleep some time and write the file again. + await Bun.sleep(10); + await Bun.write(file, "test text2."); + const lastModified1 = file.lastModified; + + // ensure the last modified timestamp is updated. + expect(lastModified1).toBeGreaterThan(lastModified0); + await gcTick(); +}); + it("Bun.file as a Blob", async () => { const filePath = path.join(import.meta.path, "../fetch.js.txt"); const fixture = fs.readFileSync(filePath, "utf8"); diff --git a/test/js/web/fetch/fetch.test.ts b/test/js/web/fetch/fetch.test.ts index 183c5dc779835..cc59ba3f6cf16 100644 --- a/test/js/web/fetch/fetch.test.ts +++ b/test/js/web/fetch/fetch.test.ts @@ -540,6 +540,7 @@ describe("Bun.file", () => { writeFileSync(path, buffer); const file = Bun.file(path); expect(blob.size).toBe(file.size); + expect(file.lastModified).toBeGreaterThan(0); return file; });