diff --git a/.changeset/silly-ligers-flash.md b/.changeset/silly-ligers-flash.md new file mode 100644 index 000000000..11b0e31b0 --- /dev/null +++ b/.changeset/silly-ligers-flash.md @@ -0,0 +1,6 @@ +--- +'@lagon/docs': minor +'@lagon/js-runtime': minor +--- + +Add Blob and File APIs diff --git a/.gitmodules b/.gitmodules index 9cf41f961..dc39c26f0 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "tools/wpt"] path = tools/wpt url = git@github.com:web-platform-tests/wpt.git + ignore = dirty diff --git a/packages/docs/pages/runtime-apis.mdx b/packages/docs/pages/runtime-apis.mdx index 2c3563f48..4ac821b5d 100644 --- a/packages/docs/pages/runtime-apis.mdx +++ b/packages/docs/pages/runtime-apis.mdx @@ -97,6 +97,14 @@ The standard `AbortController` object. [See the documentation on MDN](https://de The standard `AbortSignal` object. [See the documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal). +### `Blob` + +The standard `Blob` object. [See the documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/Blob). + +### `File` + +The standard `File` object. [See the documentation on MDN](https://developer.mozilla.org/en-US/docs/Web/API/File). + --- ### Fetch diff --git a/packages/js-runtime/package.json b/packages/js-runtime/package.json index decd9c5c8..f642f2b39 100644 --- a/packages/js-runtime/package.json +++ b/packages/js-runtime/package.json @@ -14,7 +14,6 @@ }, "dependencies": { "abortcontroller-polyfill": "^1.7.5", - "blob-polyfill": "^7.0.20220408", "web-streams-polyfill": "^3.2.1" } } \ No newline at end of file diff --git a/packages/js-runtime/src/__tests__/blob.test.ts b/packages/js-runtime/src/__tests__/blob.test.ts new file mode 100644 index 000000000..d706607a4 --- /dev/null +++ b/packages/js-runtime/src/__tests__/blob.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect } from 'vitest'; +import '../'; + +describe('Blob', () => { + it('should allow empty blobs', () => { + const blob = new Blob(); + expect(blob.size).toEqual(0); + expect(blob.type).toEqual(''); + }); + + it('should set the type', () => { + const blob = new Blob([], { type: 'text/plain' }); + expect(blob.size).toEqual(0); + expect(blob.type).toEqual('text/plain'); + }); + + it('should init with string', () => { + const blob = new Blob(['hello'], { type: 'text/plain' }); + expect(blob.size).toEqual(5); + expect(blob.type).toEqual('text/plain'); + }); + + it('should init with multiple strings', () => { + const blob = new Blob(['hello', 'world'], { type: 'text/plain' }); + expect(blob.size).toEqual(10); + expect(blob.type).toEqual('text/plain'); + }); + + it('should init with Blob', () => { + const blob = new Blob([new Blob(['hello'])], { type: 'text/plain' }); + expect(blob.size).toEqual(5); + expect(blob.type).toEqual('text/plain'); + }); + + it('should init with multiple Blobs', () => { + const blob = new Blob([new Blob(['hello']), new Blob(['world'])], { type: 'text/plain' }); + expect(blob.size).toEqual(10); + expect(blob.type).toEqual('text/plain'); + }); + + it('should init with ArrayBuffer', () => { + const blob = new Blob([new ArrayBuffer(5)], { type: 'text/plain' }); + expect(blob.size).toEqual(5); + expect(blob.type).toEqual('text/plain'); + }); + + it('should init with different types', () => { + const blob = new Blob(['hello', new ArrayBuffer(5), new Blob(['world'])], { type: 'text/plain' }); + expect(blob.size).toEqual(15); + expect(blob.type).toEqual('text/plain'); + }); + + it('should transform to ArrayBuffer', async () => { + const blob = new Blob(['hello']); + const buffer = await blob.arrayBuffer(); + expect(buffer).toBeInstanceOf(ArrayBuffer); + expect(buffer.byteLength).toEqual(5); + expect(buffer).toEqual(new TextEncoder().encode('hello').buffer); + }); + + it('should slice', async () => { + const blob = new Blob(['hello world']); + const sliced = blob.slice(6); + expect(sliced.size).toEqual(5); + expect(await sliced.text()).toEqual('world'); + }); + + it('should slice with start', async () => { + const blob = new Blob(['hello world']); + const sliced = blob.slice(6, 11); + expect(sliced.size).toEqual(5); + expect(await sliced.text()).toEqual('world'); + }); + + it('should slice with start and end', async () => { + const blob = new Blob(['hello world']); + const sliced = blob.slice(0, 5); + expect(sliced.size).toEqual(5); + expect(await sliced.text()).toEqual('hello'); + }); + + it('should slice with negative start', async () => { + const blob = new Blob(['hello world']); + const sliced = blob.slice(-5); + expect(sliced.size).toEqual(5); + expect(await sliced.text()).toEqual('world'); + }); + + it('should slice with negative start and end', async () => { + const blob = new Blob(['hello world']); + const sliced = blob.slice(-5, -1); + expect(sliced.size).toEqual(4); + expect(await sliced.text()).toEqual('worl'); + }); + + it('should transform to text', async () => { + const blob = new Blob(['hello']); + const text = await blob.text(); + expect(text).toEqual('hello'); + }); + + it('should transform to ReadableStream', async () => { + const blob = new Blob(['hello']); + const stream = blob.stream(); + const reader = stream.getReader(); + const { done, value } = await reader.read(); + expect(done).toEqual(false); + expect(value).toEqual(new TextEncoder().encode('hello')); + const { done: done2 } = await reader.read(); + expect(done2).toEqual(true); + }); +}); diff --git a/packages/js-runtime/src/__tests__/file.test.ts b/packages/js-runtime/src/__tests__/file.test.ts new file mode 100644 index 000000000..b6dc36766 --- /dev/null +++ b/packages/js-runtime/src/__tests__/file.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import '../'; + +describe('File', () => { + it('should be an instanceof Blob', () => { + const file = new File([], 'file.txt'); + expect(file).toBeInstanceOf(Blob); + }); + + it('should allow empty files', () => { + const file = new File([], 'file.txt'); + expect(file.size).toEqual(0); + expect(file.type).toEqual(''); + expect(file.name).toEqual('file.txt'); + expect(file.lastModified).toBeGreaterThan(0); + }); + + it('should set the type', () => { + const file = new File([], 'file.txt', { type: 'text/plain' }); + expect(file.size).toEqual(0); + expect(file.type).toEqual('text/plain'); + expect(file.name).toEqual('file.txt'); + expect(file.lastModified).toBeGreaterThan(0); + }); + + it('should init with string', () => { + const file = new File(['hello'], 'file.txt', { type: 'text/plain' }); + expect(file.size).toEqual(5); + expect(file.type).toEqual('text/plain'); + expect(file.name).toEqual('file.txt'); + expect(file.lastModified).toBeGreaterThan(0); + }); + + it('should set lastModified', () => { + const file = new File([], 'file.txt', { lastModified: 123 }); + expect(file.size).toEqual(0); + expect(file.type).toEqual(''); + expect(file.name).toEqual('file.txt'); + expect(file.lastModified).toEqual(123); + }); +}); diff --git a/packages/js-runtime/src/index.ts b/packages/js-runtime/src/index.ts index 4242a4cd2..3f080480c 100644 --- a/packages/js-runtime/src/index.ts +++ b/packages/js-runtime/src/index.ts @@ -6,6 +6,7 @@ import './runtime/core'; import './runtime/streams'; import './runtime/abort'; import './runtime/blob'; +import './runtime/file'; import './runtime/global/console'; import './runtime/global/process'; import './runtime/global/crypto'; @@ -69,6 +70,10 @@ declare global { interface Response { readonly isStream: boolean; } + + interface Blob { + readonly buffer: Uint8Array; + } } export async function masterHandler(request: { diff --git a/packages/js-runtime/src/runtime/blob.ts b/packages/js-runtime/src/runtime/blob.ts index 3a836f1ea..f1d7339be 100644 --- a/packages/js-runtime/src/runtime/blob.ts +++ b/packages/js-runtime/src/runtime/blob.ts @@ -1,6 +1,69 @@ -// @ts-expect-error blob-polyfill isn't typed -import { Blob } from 'blob-polyfill'; - (globalThis => { - globalThis.Blob = Blob; + globalThis.Blob = class { + readonly size: number; + readonly type: string; + readonly buffer: Uint8Array; + + constructor(blobParts?: BlobPart[], options?: BlobPropertyBag) { + if (blobParts) { + const chunks = blobParts.map(blobPart => { + if (typeof blobPart === 'string') { + return globalThis.__lagon__.TEXT_ENCODER.encode(blobPart); + } else if (blobPart instanceof ArrayBuffer || blobPart instanceof Uint8Array) { + return new Uint8Array(blobPart); + } else if (blobPart instanceof Blob) { + return blobPart.buffer as Uint8Array; + } else { + return new Uint8Array(0); + } + }); + + const totalSize = chunks.reduce((acc, chunk) => acc + chunk.byteLength, 0); + const buffer = new Uint8Array(totalSize); + let offset = 0; + + for (const chunk of chunks) { + buffer.set(chunk, offset); + offset += chunk.byteLength; + } + + this.size = buffer.byteLength; + this.buffer = buffer; + } else { + this.size = 0; + this.buffer = new Uint8Array(0); + } + + this.type = options?.type || ''; + } + + arrayBuffer(): Promise { + return Promise.resolve(this.buffer.buffer); + } + + slice(start?: number, end?: number, contentType?: string): Blob { + let type = contentType; + + if (type === undefined) { + type = this.type; + } else if (type === null) { + type = 'null'; + } + + return new Blob([this.buffer.slice(start, end)], { type }); + } + + stream(): ReadableStream { + return new ReadableStream({ + pull: async controller => { + controller.enqueue(this.buffer); + controller.close(); + }, + }); + } + + text(): Promise { + return Promise.resolve(globalThis.__lagon__.TEXT_DECODER.decode(this.buffer)); + } + }; })(globalThis); diff --git a/packages/js-runtime/src/runtime/file.ts b/packages/js-runtime/src/runtime/file.ts new file mode 100644 index 000000000..453cd91aa --- /dev/null +++ b/packages/js-runtime/src/runtime/file.ts @@ -0,0 +1,45 @@ +(globalThis => { + globalThis.File = class extends Blob { + readonly lastModified: number; + readonly name: string; + readonly webkitRelativePath: string; + + constructor(fileBits: BlobPart[], fileName: string, options?: FilePropertyBag) { + super(fileBits, options); + + this.lastModified = options?.lastModified || Date.now(); + this.name = fileName; + this.webkitRelativePath = ''; + } + }; + + // TODO: properly implement FileReader. It should extends Event + // @ts-expect-errore to fix + globalThis.FileReader = class { + // @ts-expect-errore to fix + readonly DONE: number; + // @ts-expect-errore to fix + readonly EMPTY: number; + // @ts-expect-errore to fix + readonly LOADING: number; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + constructor() {} + + // @ts-expect-errore to fix + // eslint-disable-next-line @typescript-eslint/no-empty-function + onerror(): ((this: FileReader, ev: ProgressEvent) => any) | null {} + // @ts-expect-errore to fix + // eslint-disable-next-line @typescript-eslint/no-empty-function + onload(): ((this: FileReader, ev: ProgressEvent) => any) | null {} + + readAsText(blob: Blob, encoding?: string) { + blob.text().then(text => { + // @ts-expect-errore to fix + this.result = text; + // @ts-expect-errore to fix + this.onload(this); + }); + } + }; +})(globalThis); diff --git a/packages/js-runtime/tsconfig.json b/packages/js-runtime/tsconfig.json index 9a70533a1..b1e5b8b24 100644 --- a/packages/js-runtime/tsconfig.json +++ b/packages/js-runtime/tsconfig.json @@ -1,7 +1,11 @@ { "compilerOptions": { "target": "ES2020", - "lib": ["ES2022", "WebWorker", "Webworker.Iterable"], + "lib": [ + "ES2022", + "WebWorker", + "Webworker.Iterable" + ], "types": [], // Exclude node types "module": "ESNext", "moduleResolution": "Node", @@ -13,5 +17,7 @@ "incremental": true, "tsBuildInfoFile": "tsconfig.tsbuildinfo", }, - "exclude": ["./dist/**/*"], -} + "exclude": [ + "./dist/**/*" + ], +} \ No newline at end of file diff --git a/packages/runtime/src/runtime/mod.rs b/packages/runtime/src/runtime/mod.rs index b0132728e..1cf7e63c3 100644 --- a/packages/runtime/src/runtime/mod.rs +++ b/packages/runtime/src/runtime/mod.rs @@ -10,6 +10,8 @@ struct IcuData([u8; 10454784]); static JS_RUNTIME: &str = include_str!("../../runtime.js"); static ICU_DATA: IcuData = IcuData(*include_bytes!("../../icudtl.dat")); +const FLAGS: [&str; 0] = []; + lazy_static! { pub static ref POOL: LocalPoolHandle = LocalPoolHandle::new(1); } @@ -17,6 +19,7 @@ lazy_static! { #[derive(Default)] pub struct RuntimeOptions { allow_code_generation: bool, + expose_gc: bool, } impl RuntimeOptions { @@ -24,6 +27,11 @@ impl RuntimeOptions { self.allow_code_generation = allow_code_generation; self } + + pub fn with_expose_gc(mut self, expose_gc: bool) -> Self { + self.expose_gc = expose_gc; + self + } } pub struct Runtime; @@ -34,11 +42,19 @@ impl Runtime { // https://github.com/denoland/deno/blob/a55b194638bcaace38917703b7d9233fb1989d44/core/runtime.rs#L223 v8::icu::set_common_data_71(&ICU_DATA.0).expect("Failed to load ICU data"); + let mut flags = FLAGS.join(" "); + // Disable code generation from `eval` / `new Function` if !options.allow_code_generation { - V8::set_flags_from_string("--disallow-code-generation-from-strings"); + flags += " --disallow-code-generation-from-strings"; + } + + if options.expose_gc { + flags += " --expose-gc"; } + V8::set_flags_from_string(&flags); + let platform = v8::new_default_platform(0, false).make_shared(); V8::initialize_platform(platform); V8::initialize(); diff --git a/packages/wpt-runner/src/main.rs b/packages/wpt-runner/src/main.rs index a5f7539f3..647ca3c5b 100644 --- a/packages/wpt-runner/src/main.rs +++ b/packages/wpt-runner/src/main.rs @@ -12,6 +12,7 @@ use lagon_runtime::{ }; const ENCODING_TABLE: &str = include_str!("../../../tools/wpt/encoding/resources/encodings.js"); +const SUPPORT_BLOB: &str = include_str!("../../../tools/wpt/FileAPI/support/Blob.js"); lazy_static! { static ref RESULT: Mutex<(usize, usize, usize)> = Mutex::new((0, 0, 0)); @@ -101,10 +102,11 @@ async fn run_test(path: &Path) { export function handler() {{ {} {ENCODING_TABLE} + {SUPPORT_BLOB} {code} return new Response() }}", - TEST_HARNESS.as_str() + TEST_HARNESS.as_str(), ) .replace("self.", "globalThis."); @@ -131,7 +133,7 @@ async fn test_directory(path: &Path) { #[tokio::main] async fn main() { - let runtime = Runtime::new(RuntimeOptions::default()); + let runtime = Runtime::new(RuntimeOptions::default().with_expose_gc(true)); init_logger().expect("Failed to initialize logger"); if let Some(path) = env::args().nth(1) { @@ -152,6 +154,7 @@ async fn main() { // Enable when CompressionStream/DecompressionStream are implemented // test_directory(Path::new("../../tools/wpt/compression")).await; test_directory(Path::new("../../tools/wpt/encoding")).await; + test_directory(Path::new("../../tools/wpt/FileAPI/blob")).await; } let result = RESULT.lock().unwrap(); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 312f4d8f8..6c25352d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -277,11 +277,9 @@ importers: packages/js-runtime: specifiers: abortcontroller-polyfill: ^1.7.5 - blob-polyfill: ^7.0.20220408 web-streams-polyfill: ^3.2.1 dependencies: abortcontroller-polyfill: 1.7.5 - blob-polyfill: 7.0.20220408 web-streams-polyfill: 3.2.1 packages/runtime: @@ -9060,10 +9058,6 @@ packages: readable-stream: 3.6.0 dev: true - /blob-polyfill/7.0.20220408: - resolution: {integrity: sha512-oD8Ydw+5lNoqq+en24iuPt1QixdPpe/nUF8azTHnviCZYu9zUC+TwdzIp5orpblJosNlgNbVmmAb//c6d6ImUQ==} - dev: false - /bluebird/3.7.2: resolution: {integrity: sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==} dev: true diff --git a/vitest.config.mts b/vitest.config.mts index cd38e29e3..dc0285956 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -1,9 +1,7 @@ import { defineConfig } from 'vitest/config'; -import tsconfigPaths from 'vite-tsconfig-paths'; export default defineConfig({ test: { silent: true, }, - plugins: [tsconfigPaths()], });