Skip to content

Commit

Permalink
feat(js-runtime): new Blob & File implementations (#403)
Browse files Browse the repository at this point in the history
* feat(js-runtime): new Blob implementation

* feat: enable more tests

* chore: add changesets

* feat(docs): add docs for Blob & File

* fix(js-runtime): complete Blob & File APIs

* test(js-runtime): add Blob & File APIs
  • Loading branch information
QuiiBz committed Dec 24, 2022
1 parent d4f5215 commit 5e2ca1b
Show file tree
Hide file tree
Showing 14 changed files with 316 additions and 19 deletions.
6 changes: 6 additions & 0 deletions .changeset/silly-ligers-flash.md
@@ -0,0 +1,6 @@
---
'@lagon/docs': minor
'@lagon/js-runtime': minor
---

Add Blob and File APIs
1 change: 1 addition & 0 deletions .gitmodules
@@ -1,3 +1,4 @@
[submodule "tools/wpt"]
path = tools/wpt
url = git@github.com:web-platform-tests/wpt.git
ignore = dirty
8 changes: 8 additions & 0 deletions packages/docs/pages/runtime-apis.mdx
Expand Up @@ -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
Expand Down
1 change: 0 additions & 1 deletion packages/js-runtime/package.json
Expand Up @@ -14,7 +14,6 @@
},
"dependencies": {
"abortcontroller-polyfill": "^1.7.5",
"blob-polyfill": "^7.0.20220408",
"web-streams-polyfill": "^3.2.1"
}
}
112 changes: 112 additions & 0 deletions 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);
});
});
41 changes: 41 additions & 0 deletions 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);
});
});
5 changes: 5 additions & 0 deletions packages/js-runtime/src/index.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -69,6 +70,10 @@ declare global {
interface Response {
readonly isStream: boolean;
}

interface Blob {
readonly buffer: Uint8Array;
}
}

export async function masterHandler(request: {
Expand Down
71 changes: 67 additions & 4 deletions 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<ArrayBuffer> {
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<Uint8Array> {
return new ReadableStream({
pull: async controller => {
controller.enqueue(this.buffer);
controller.close();
},
});
}

text(): Promise<string> {
return Promise.resolve(globalThis.__lagon__.TEXT_DECODER.decode(this.buffer));
}
};
})(globalThis);
45 changes: 45 additions & 0 deletions 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<FileReader>) => any) | null {}
// @ts-expect-errore to fix
// eslint-disable-next-line @typescript-eslint/no-empty-function
onload(): ((this: FileReader, ev: ProgressEvent<FileReader>) => 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);
12 changes: 9 additions & 3 deletions 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",
Expand All @@ -13,5 +17,7 @@
"incremental": true,
"tsBuildInfoFile": "tsconfig.tsbuildinfo",
},
"exclude": ["./dist/**/*"],
}
"exclude": [
"./dist/**/*"
],
}

4 comments on commit 5e2ca1b

@vercel
Copy link

@vercel vercel bot commented on 5e2ca1b Dec 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

www – ./www

www-lagon.vercel.app
www-git-main-lagon.vercel.app
lagon.app

@vercel
Copy link

@vercel vercel bot commented on 5e2ca1b Dec 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

docs – ./packages/docs

docs-lagon.vercel.app
lagon-docs.vercel.app
docs-git-main-lagon.vercel.app
docs.lagon.app

@vercel
Copy link

@vercel vercel bot commented on 5e2ca1b Dec 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

dashboard – ./packages/dashboard

dashboard-git-main-lagon.vercel.app
dashboard-lagon.vercel.app
dash.lagon.app

@vercel
Copy link

@vercel vercel bot commented on 5e2ca1b Dec 24, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

storybook – ./packages/ui

storybook-lagon.vercel.app
storybook-swart-eight.vercel.app
storybook-git-main-lagon.vercel.app
ui.lagon.app

Please sign in to comment.