Skip to content

Commit 2fc671b

Browse files
bkaradzic-microsoftbkaradzicCopilot
authored
Add File / FileReader polyfill (#169)
The File polyfill in BabylonNative currently lives as a JS shim (`Apps/Playground/Scripts/file_polyfill.js`, ~308 lines) that is `LoadScript`-ed from the Playground's `AppContext` (see BabylonJS/BabylonNative#1706). Per @bghgary's review on that PR, it should live in JsRuntimeHost alongside Blob, URL, WebSocket, XMLHttpRequest, TextDecoder, AbortController, etc., so every JsRuntimeHost consumer -- not only the BabylonNative Playground -- gets `File` and `FileReader`. This PR adds a native C++ `Polyfills/File/` target that implements the WHATWG `File` and `FileReader` web APIs, and 28 new Mocha unit tests (13 File + 15 FileReader) under `Tests/UnitTests/Scripts/tests.ts`. ### Surface area * `File(parts, name, options)` constructor. * Instance accessors: `size`, `type`, `name`, `lastModified`. * Instance methods: `arrayBuffer()`, `text()`, `bytes()`. * `FileReader` with state constants `EMPTY/LOADING/DONE` (both on the constructor and on instances, per WHATWG). * `FileReader` events: `loadstart`, `load`, `loadend`, `progress`, `error`, `abort` via both `onX` handler slots and `addEventListener` / `removeEventListener`. * `readAsText` / `readAsArrayBuffer` / `readAsDataURL` / `readAsBinaryString` / `abort`. ### Implementation notes * File delegates its byte storage to the existing `Blob` polyfill via `env.Global().Get("Blob")` so we reuse Blob's `BlobPart` handling (`ArrayBuffer`, typed array, string, Blob). * FileReader's base64 encoder for `readAsDataURL` is an inlined RFC 4648 implementation -- JsRuntimeHost has no base-n dependency. * The new `JSRUNTIMEHOST_POLYFILL_FILE` CMake option is on by default and gates building the target. ### Notable JSC-specific care Two pieces of the implementation are shaped specifically to work correctly on JavaScriptCore: 1. **`FileReader.EMPTY`/`LOADING`/`DONE` are registered via `StaticValue` and `InstanceValue` descriptors inside the `DefineClass` property list, not via `func.Get("prototype").Set(...)` after class creation.** On JSC the napi shim's `ConstructorInfo::Create` defaults the constructor's `.prototype` to `Object.prototype`, so the latter pattern would pollute `Object.prototype` with `EMPTY/LOADING/DONE` keys and break `for..in` over plain objects throughout the runtime. The `Tests/UnitTests/Scripts/tests.ts` regression test `"does not pollute Object.prototype with EMPTY/LOADING/DONE"` pins this behaviour. 2. **The Blob-dependency guard in `File::Initialize` uses `IsUndefined()` rather than `IsFunction()`.** Some JSC builds (notably `libjavascriptcoregtk-4.1` on Linux) classify constructors created via `JSObjectMakeConstructor` as `typeof 'object'`, not `'function'`, so `napi_typeof` returns `napi_object` for them and an `IsFunction()` guard would silently early-return on those engines. `IsUndefined()` matches the guard used by Blob and works on V8, JSI, Chakra, iOS-JSC, Android-JSC, and Linux JSC. ### Testing Locally: `UnitTests.exe` on Win32 V8 Release passes 167/167 tests (139 pre-existing + 28 new), including the Object.prototype-pollution regression canary. End-to-end with BabylonNative: with this PR pinned in `BabylonJS/BabylonNative#1706`, BabylonNative CI passes 26/26 jobs (all platforms including Linux Clang/GCC JSC), and the 19 GLTF/OBJ playground tests that previously depended on the JS shim are re-enabled and passing. ### After this merges `BabylonJS/BabylonNative#1706` re-pins `CMakeLists.txt`'s `GIT_REPOSITORY`/`GIT_TAG` to the merged SHA and then merges. --------- Co-authored-by: Branimir Karadzic <branimirkaradzic@gmail.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a128e68 commit 2fc671b

13 files changed

Lines changed: 1233 additions & 0 deletions

File tree

CMakeLists.txt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,10 @@ FetchContent_Declare(asio
2121
GIT_REPOSITORY https://github.com/chriskohlhoff/asio.git
2222
GIT_TAG f693a3eb7fe72a5f19b975289afc4f437d373d9c
2323
EXCLUDE_FROM_ALL)
24+
FetchContent_Declare(base-n
25+
GIT_REPOSITORY https://github.com/azawadzki/base-n.git
26+
GIT_TAG 7573e77c0b9b0e8a5fb63d96dbde212c921993b4
27+
EXCLUDE_FROM_ALL)
2428
FetchContent_Declare(CMakeExtensions
2529
GIT_REPOSITORY https://github.com/BabylonJS/CMakeExtensions.git
2630
GIT_TAG dc750e7f69dad76779419df6442f834c57a30a1f
@@ -80,6 +84,7 @@ option(JSRUNTIMEHOST_POLYFILL_URL "Include JsRuntimeHost Polyfill URL and URLSea
8084
option(JSRUNTIMEHOST_POLYFILL_ABORT_CONTROLLER "Include JsRuntimeHost Polyfills AbortController and AbortSignal." ON)
8185
option(JSRUNTIMEHOST_POLYFILL_WEBSOCKET "Include JsRuntimeHost Polyfill WebSocket." ON)
8286
option(JSRUNTIMEHOST_POLYFILL_BLOB "Include JsRuntimeHost Polyfill Blob." ON)
87+
option(JSRUNTIMEHOST_POLYFILL_FILE "Include JsRuntimeHost Polyfill File and FileReader." ON)
8388
option(JSRUNTIMEHOST_POLYFILL_PERFORMANCE "Include JsRuntimeHost Polyfill Performance." ON)
8489
option(JSRUNTIMEHOST_POLYFILL_TEXTDECODER "Include JsRuntimeHost Polyfill TextDecoder." ON)
8590
option(JSRUNTIMEHOST_POLYFILL_TEXTENCODER "Include JsRuntimeHost Polyfill TextEncoder." ON)
@@ -140,6 +145,13 @@ if(JSRUNTIMEHOST_POLYFILL_XMLHTTPREQUEST)
140145
set_property(TARGET UrlLib PROPERTY FOLDER Dependencies)
141146
endif()
142147

148+
if(JSRUNTIMEHOST_POLYFILL_FILE)
149+
FetchContent_MakeAvailable_With_Message(base-n)
150+
add_library(base-n INTERFACE)
151+
target_include_directories(base-n INTERFACE "${base-n_SOURCE_DIR}/include")
152+
set_property(TARGET base-n PROPERTY FOLDER Dependencies)
153+
endif()
154+
143155
if(BABYLON_DEBUG_TRACE)
144156
add_definitions(-DBABYLON_DEBUG_TRACE)
145157
endif()

Polyfills/CMakeLists.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ if(JSRUNTIMEHOST_POLYFILL_BLOB)
2626
add_subdirectory(Blob)
2727
endif()
2828

29+
if(JSRUNTIMEHOST_POLYFILL_FILE)
30+
add_subdirectory(File)
31+
endif()
32+
2933
if(JSRUNTIMEHOST_POLYFILL_PERFORMANCE)
3034
add_subdirectory(Performance)
3135
endif()

Polyfills/File/CMakeLists.txt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
set(SOURCES
2+
"Include/Babylon/Polyfills/File.h"
3+
"Source/File.h"
4+
"Source/File.cpp"
5+
"Source/FileReader.h"
6+
"Source/FileReader.cpp")
7+
8+
add_library(File ${SOURCES})
9+
warnings_as_errors(File)
10+
11+
target_include_directories(File PUBLIC "Include")
12+
13+
target_link_libraries(File
14+
PRIVATE base-n
15+
PUBLIC JsRuntime)
16+
17+
set_property(TARGET File PROPERTY FOLDER Polyfills)
18+
source_group(TREE ${CMAKE_CURRENT_SOURCE_DIR} FILES ${SOURCES})
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
#pragma once
2+
3+
#include <napi/env.h>
4+
#include <Babylon/Api.h>
5+
6+
namespace Babylon::Polyfills::File
7+
{
8+
void BABYLON_API Initialize(Napi::Env env);
9+
}

Polyfills/File/Readme.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# File
2+
3+
Implements the `File` and `FileReader` web APIs on top of the native `Blob`
4+
polyfill provided by JsRuntimeHost. Babylon.js GLTF/OBJ serializer
5+
round-trip codepaths construct `new File([blob], 'scene.glb')` and read it
6+
back via `FileReader.readAsArrayBuffer(...)`, so the runtime needs both
7+
constructors for those tests (and any consumer code that wraps serializer
8+
output) to work.
9+
10+
## Behaviour
11+
12+
* No-op when the runtime already exposes a global `File` / `FileReader`
13+
(e.g. V8 in some embeddings).
14+
* `File` is self-contained: the constructor delegates to the global
15+
`Blob` constructor to build the underlying byte storage, then decorates
16+
the instance with `name` and `lastModified`. Methods (`size`, `type`,
17+
`arrayBuffer`, `text`, `bytes`) forward to the inner `Blob`.
18+
* `FileReader` supports `readAsArrayBuffer`, `readAsText`, and
19+
`readAsDataURL`, plus `abort`, `addEventListener` /
20+
`removeEventListener` / `dispatchEvent`, and the standard `onload` /
21+
`onerror` / `onloadstart` / `onloadend` / `onprogress` / `onabort`
22+
handler slots. `abort()` invalidates in-flight reads via a monotonic
23+
token so late-resolving `arrayBuffer()` promises cannot dispatch a
24+
phantom `load` event after a user-initiated abort.
25+
* `File` extends `Blob`: the JS-visible prototype chain is wired so
26+
`new File(...) instanceof Blob === true`. Babylon.js core branches on
27+
`instanceof Blob` in several places (fileTools, Offline/database,
28+
abstractEngine, thinNativeEngine).
29+
30+
## Prerequisites
31+
32+
`Babylon::Polyfills::Blob::Initialize(env)` (from JsRuntimeHost) must be
33+
called before `Babylon::Polyfills::File::Initialize(env)`; if `Blob` is
34+
missing from the global object when `File::Initialize` runs, the `File`
35+
constructor will not be registered.

Polyfills/File/Source/File.cpp

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
#include "File.h"
2+
#include "FileReader.h"
3+
4+
#include <Babylon/Polyfills/File.h>
5+
6+
#include <chrono>
7+
#include <string>
8+
9+
namespace Babylon::Polyfills::Internal
10+
{
11+
namespace
12+
{
13+
constexpr auto JS_FILE_CONSTRUCTOR_NAME = "File";
14+
constexpr auto JS_BLOB_CONSTRUCTOR_NAME = "Blob";
15+
}
16+
17+
void File::Initialize(Napi::Env env)
18+
{
19+
auto global = env.Global();
20+
21+
// No-op if the runtime already provides a global File. Cheapest
22+
// check, and the common path on platforms with a native File.
23+
if (!global.Get(JS_FILE_CONSTRUCTOR_NAME).IsUndefined())
24+
{
25+
return;
26+
}
27+
28+
// Require the native Blob polyfill: File delegates byte storage to
29+
// a Blob, so without it the constructor cannot produce useful
30+
// instances. Use IsUndefined() rather than IsFunction() because
31+
// some JavaScriptCore builds (notably libjavascriptcoregtk on
32+
// Linux) classify constructors created via JSObjectMakeConstructor
33+
// as typeof 'object', not 'function', so napi_typeof returns
34+
// napi_object for them.
35+
auto blob = global.Get(JS_BLOB_CONSTRUCTOR_NAME);
36+
if (blob.IsUndefined() || blob.IsNull())
37+
{
38+
throw Napi::Error::New(env,
39+
"File polyfill requires the Blob polyfill to be installed first.");
40+
}
41+
42+
Napi::Function func = DefineClass(
43+
env,
44+
JS_FILE_CONSTRUCTOR_NAME,
45+
{
46+
InstanceAccessor("size", &File::GetSize, nullptr),
47+
InstanceAccessor("type", &File::GetType, nullptr),
48+
InstanceAccessor("name", &File::GetName, nullptr),
49+
InstanceAccessor("lastModified", &File::GetLastModified, nullptr),
50+
InstanceMethod("arrayBuffer", &File::ArrayBuffer),
51+
InstanceMethod("text", &File::Text),
52+
InstanceMethod("bytes", &File::Bytes),
53+
});
54+
55+
global.Set(JS_FILE_CONSTRUCTOR_NAME, func);
56+
57+
// Wire File.prototype's [[Prototype]] to Blob.prototype so
58+
// `new File(...) instanceof Blob === true`. WHATWG specs File as
59+
// a Blob subtype; BJS core (fileTools, Offline/database,
60+
// abstractEngine, thinNativeEngine) branches on `instanceof Blob`
61+
// and needs File inputs to satisfy that check.
62+
auto setPrototypeOf = env.Global().Get("Object").As<Napi::Object>()
63+
.Get("setPrototypeOf").As<Napi::Function>();
64+
setPrototypeOf.Call({
65+
func.Get("prototype"),
66+
blob.As<Napi::Function>().Get("prototype"),
67+
});
68+
}
69+
70+
File::File(const Napi::CallbackInfo& info)
71+
: Napi::ObjectWrap<File>{info}
72+
{
73+
auto env = info.Env();
74+
75+
// The WHATWG File constructor takes (fileBits, fileName, [options]).
76+
// Both fileBits and fileName are required (USVString without
77+
// `optional`), so missing either is a TypeError per WebIDL bindings.
78+
if (info.Length() < 2)
79+
{
80+
throw Napi::TypeError::New(env,
81+
"Failed to construct 'File': 2 arguments required, but only " +
82+
std::to_string(info.Length()) + " present.");
83+
}
84+
85+
Napi::Value parts = info[0];
86+
Napi::Value name = info[1];
87+
Napi::Value options = info.Length() > 2 ? info[2] : env.Undefined();
88+
89+
// USVString conversion: undefined -> "undefined", null -> "null",
90+
// numbers/objects -> their .toString() representation. Napi::Value::
91+
// ToString() routes through napi_coerce_to_string, which matches
92+
// these semantics on all three engines.
93+
m_name = name.ToString().Utf8Value();
94+
95+
// Default lastModified to the current wall clock in milliseconds,
96+
// matching Date.now() semantics used by the JS File constructor.
97+
m_lastModified = static_cast<double>(
98+
std::chrono::duration_cast<std::chrono::milliseconds>(
99+
std::chrono::system_clock::now().time_since_epoch())
100+
.count());
101+
102+
auto blobOptions = Napi::Object::New(env);
103+
104+
if (options.IsObject())
105+
{
106+
auto optsObj = options.As<Napi::Object>();
107+
if (optsObj.Has("type"))
108+
{
109+
blobOptions.Set("type", optsObj.Get("type"));
110+
}
111+
if (optsObj.Has("lastModified"))
112+
{
113+
auto lm = optsObj.Get("lastModified");
114+
if (lm.IsNumber())
115+
{
116+
m_lastModified = lm.As<Napi::Number>().DoubleValue();
117+
}
118+
}
119+
}
120+
121+
Napi::Value partsArray;
122+
if (parts.IsArray())
123+
{
124+
partsArray = parts;
125+
}
126+
else
127+
{
128+
partsArray = Napi::Array::New(env, 0);
129+
}
130+
131+
// Delegate byte-buffer construction to the native Blob polyfill so
132+
// we benefit from its existing BlobPart handling (ArrayBuffer,
133+
// typed array, string, Blob).
134+
auto blobCtor = env.Global().Get(JS_BLOB_CONSTRUCTOR_NAME).As<Napi::Function>();
135+
auto blobInstance = blobCtor.New({partsArray, blobOptions});
136+
m_blob = Napi::Persistent(blobInstance);
137+
}
138+
139+
Napi::Value File::GetSize(const Napi::CallbackInfo&)
140+
{
141+
return m_blob.Value().Get("size");
142+
}
143+
144+
Napi::Value File::GetType(const Napi::CallbackInfo&)
145+
{
146+
return m_blob.Value().Get("type");
147+
}
148+
149+
Napi::Value File::GetName(const Napi::CallbackInfo& info)
150+
{
151+
return Napi::String::New(info.Env(), m_name);
152+
}
153+
154+
Napi::Value File::GetLastModified(const Napi::CallbackInfo& info)
155+
{
156+
return Napi::Number::New(info.Env(), m_lastModified);
157+
}
158+
159+
Napi::Value File::ArrayBuffer(const Napi::CallbackInfo&)
160+
{
161+
auto blob = m_blob.Value();
162+
return blob.Get("arrayBuffer").As<Napi::Function>().Call(blob, {});
163+
}
164+
165+
Napi::Value File::Text(const Napi::CallbackInfo&)
166+
{
167+
auto blob = m_blob.Value();
168+
return blob.Get("text").As<Napi::Function>().Call(blob, {});
169+
}
170+
171+
Napi::Value File::Bytes(const Napi::CallbackInfo&)
172+
{
173+
auto blob = m_blob.Value();
174+
return blob.Get("bytes").As<Napi::Function>().Call(blob, {});
175+
}
176+
}
177+
178+
namespace Babylon::Polyfills::File
179+
{
180+
void BABYLON_API Initialize(Napi::Env env)
181+
{
182+
Internal::File::Initialize(env);
183+
Internal::FileReader::Initialize(env);
184+
}
185+
}

Polyfills/File/Source/File.h

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#pragma once
2+
3+
#include <napi/napi.h>
4+
5+
#include <string>
6+
7+
namespace Babylon::Polyfills::Internal
8+
{
9+
class File final : public Napi::ObjectWrap<File>
10+
{
11+
public:
12+
static void Initialize(Napi::Env env);
13+
14+
explicit File(const Napi::CallbackInfo& info);
15+
16+
private:
17+
Napi::Value GetSize(const Napi::CallbackInfo& info);
18+
Napi::Value GetType(const Napi::CallbackInfo& info);
19+
Napi::Value GetName(const Napi::CallbackInfo& info);
20+
Napi::Value GetLastModified(const Napi::CallbackInfo& info);
21+
22+
Napi::Value ArrayBuffer(const Napi::CallbackInfo& info);
23+
Napi::Value Text(const Napi::CallbackInfo& info);
24+
Napi::Value Bytes(const Napi::CallbackInfo& info);
25+
26+
Napi::ObjectReference m_blob;
27+
std::string m_name;
28+
double m_lastModified{0.0};
29+
};
30+
}

0 commit comments

Comments
 (0)