feat: migrate more things to streams

This commit is contained in:
Simon Chan 2022-02-17 18:26:03 +08:00
parent b7725567a6
commit ef57682ec3
20 changed files with 2239 additions and 2007 deletions

View file

@ -1,6 +1,6 @@
// cspell: ignore scrollback
import { AdbShell, encodeUtf8 } from "@yume-chan/adb";
import { AdbSubprocessProtocol, encodeUtf8 } from "@yume-chan/adb";
import { AutoDisposable } from "@yume-chan/event";
import { Terminal } from 'xterm';
import { FitAddon } from 'xterm-addon-fit';
@ -18,29 +18,43 @@ export class AdbTerminal extends AutoDisposable {
private readonly fitAddon = new FitAddon();
private _shell: AdbShell | undefined;
public get socket() { return this._shell; }
private _socket: AdbSubprocessProtocol | undefined;
private _socketAbortController: AbortController | undefined;
public get socket() { return this._socket; }
public set socket(value) {
if (this._shell) {
if (this._socket) {
// Remove event listeners
this.dispose();
this._socketAbortController?.abort();
}
this._shell = value;
this._socket = value;
if (value) {
this.terminal.clear();
this.terminal.reset();
this.addDisposable(value.onStdout(data => {
this.terminal.write(new Uint8Array(data));
}));
this.addDisposable(value.onStderr(data => {
this.terminal.write(new Uint8Array(data));
}));
this._socketAbortController = new AbortController();
value.stdout.pipeTo(new WritableStream({
write: (chunk) => {
this.terminal.write(new Uint8Array(chunk));
},
}), {
signal: this._socketAbortController.signal,
});
value.stderr.pipeTo(new WritableStream({
write: (chunk) => {
this.terminal.write(new Uint8Array(chunk));
},
}), {
signal: this._socketAbortController.signal,
});
const _writer = value.stdin.getWriter();
this.addDisposable(this.terminal.onData(data => {
const buffer = encodeUtf8(data);
value.write(buffer);
_writer.write(buffer);
}));
this.fit();
@ -77,6 +91,6 @@ export class AdbTerminal extends AutoDisposable {
this.fitAddon.fit();
// Resize remote terminal
const { rows, cols } = this.terminal;
this._shell?.resize(rows, cols);
this._socket?.resize(rows, cols);
}
}

View file

@ -3,7 +3,7 @@ import { FileIconType } from "@fluentui/react-file-type-icons";
import { getFileTypeIconNameFromExtensionOrType } from '@fluentui/react-file-type-icons/lib-commonjs/getFileTypeIconProps';
import { DEFAULT_BASE_URL as FILE_TYPE_ICONS_BASE_URL } from '@fluentui/react-file-type-icons/lib-commonjs/initializeFileTypeIcons';
import { useConst } from '@fluentui/react-hooks';
import { AdbSyncEntryResponse, AdbSyncMaxPacketSize, LinuxFileType } from '@yume-chan/adb';
import { AdbSyncEntryResponse, ADB_SYNC_MAX_PACKET_SIZE, LinuxFileType } from '@yume-chan/adb';
import { action, autorun, makeAutoObservable, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
@ -16,6 +16,23 @@ import { CommandBar, NoSsr } from '../components';
import { globalState } from '../state';
import { asyncEffect, chunkFile, formatSize, formatSpeed, Icons, pickFile, RouteStackProps } from '../utils';
/**
* Because of internal buffer of upstream/downstream streams,
* the progress value won't be 100% accurate. But it's usually good enough.
*/
export class ProgressStream extends TransformStream<ArrayBuffer, ArrayBuffer> {
public constructor(onProgress: (value: number) => void) {
let progress = 0;
super({
transform(chunk, controller) {
progress += chunk.byteLength;
onProgress(progress);
controller.enqueue(chunk);
}
});
}
}
let StreamSaver: typeof import('streamsaver');
if (typeof window !== 'undefined') {
const { publicRuntimeConfig } = getConfig();
@ -169,7 +186,7 @@ class FileManagerState {
const sync = await globalState.device!.sync();
try {
const itemPath = path.resolve(this.path, this.selectedItems[0].name!);
const readableStream = createReadableStreamFromBufferIterator(sync.read(itemPath));
const readableStream = sync.read(itemPath);
const writeableStream = StreamSaver!.createWriteStream(this.selectedItems[0].name!, {
size: this.selectedItems[0].size,
@ -473,15 +490,17 @@ class FileManagerState {
}), 1000);
try {
await sync.write(
const writable = sync.write(
itemPath,
chunkFile(file, AdbSyncMaxPacketSize),
chunkFile(file, ADB_SYNC_MAX_PACKET_SIZE),
(LinuxFileType.File << 12) | 0o666,
file.lastModified / 1000,
action((uploaded) => {
this.uploadedSize = uploaded;
}),
);
const readable: ReadableStream<ArrayBuffer> = file.stream();
readable.pipeThrough();
runInAction(() => {
this.uploadSpeed = this.uploadedSize - this.debouncedUploadedSize;
this.debouncedUploadedSize = this.uploadedSize;

View file

@ -1,5 +1,5 @@
import { DefaultButton, ProgressIndicator, Stack } from "@fluentui/react";
import { AdbSyncMaxPacketSize } from "@yume-chan/adb";
import { ADB_SYNC_MAX_PACKET_SIZE } from "@yume-chan/adb";
import { makeAutoObservable, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
import { NextPage } from "next";
@ -7,6 +7,7 @@ import Head from "next/head";
import React from "react";
import { globalState } from "../state";
import { chunkFile, pickFile, RouteStackProps } from "../utils";
import { ProgressStream } from './file-manager';
enum Stage {
Uploading,
@ -57,6 +58,8 @@ class InstallPageState {
};
});
setTimeout(handler);
const readable = file.stream();
await globalState.device!.install(chunkFile(file, AdbSyncMaxPacketSize), uploaded => {
runInAction(() => {
if (uploaded !== file.size) {

View file

@ -1,7 +1,8 @@
import { CommandBar, Dialog, Dropdown, ICommandBarItemProps, Icon, IconButton, IDropdownOption, LayerHost, Position, ProgressIndicator, SpinButton, Stack, Toggle, TooltipHost } from "@fluentui/react";
import { useId } from "@fluentui/react-hooks";
import { TransformStream } from '@yume-chan/adb';
import { EventEmitter } from "@yume-chan/event";
import { AndroidKeyCode, AndroidKeyEventAction, AndroidMotionEventAction, CodecOptions, DEFAULT_SERVER_PATH, H264Decoder, H264DecoderConstructor, pushServer, ScrcpyClient, ScrcpyLogLevel, ScrcpyOptions1_22, ScrcpyScreenOrientation, TinyH264Decoder, WebCodecsDecoder } from "@yume-chan/scrcpy";
import { AndroidKeyCode, AndroidKeyEventAction, AndroidMotionEventAction, CodecOptions, DEFAULT_SERVER_PATH, H264Decoder, H264DecoderConstructor, pushServerStream, ScrcpyClient, ScrcpyLogLevel, ScrcpyOptions1_22, ScrcpyScreenOrientation, TinyH264Decoder, WebCodecsDecoder } from "@yume-chan/scrcpy";
import SCRCPY_SERVER_VERSION from '@yume-chan/scrcpy/bin/version';
import { action, autorun, makeAutoObservable, observable, runInAction } from "mobx";
import { observer } from "mobx-react-lite";
@ -406,11 +407,9 @@ class ScrcpyPageState {
}), 1000);
try {
await pushServer(globalState.device, serverBuffer, {
onProgress: action((progress) => {
this.serverUploadedSize = progress;
}),
});
const writable = await pushServerStream(globalState.device);
// TODO: Scrcpy: push server
runInAction(() => {
this.serverUploadSpeed = this.serverUploadedSize - this.debouncedServerUploadedSize;
this.debouncedServerUploadedSize = this.serverUploadedSize;
@ -441,7 +440,8 @@ class ScrcpyPageState {
// Run scrcpy once will delete the server file
// Re-push it
await pushServer(globalState.device, serverBuffer);
const writable = await pushServerStream(globalState.device);
// TODO: Scrcpy: push server
const factory = this.selectedDecoder.factory;
const decoder = new factory();
@ -449,30 +449,6 @@ class ScrcpyPageState {
this.decoder = decoder;
});
const client = new ScrcpyClient(globalState.device);
runInAction(() => this.log = []);
client.onOutput(action(line => this.log.push(line)));
client.onClose(this.stop);
client.onEncodingChanged(action((encoding) => {
const { croppedWidth, croppedHeight, } = encoding;
this.log.push(`[client] Video size changed: ${croppedWidth}x${croppedHeight}`);
this.width = croppedWidth;
this.height = croppedHeight;
decoder.changeEncoding(encoding);
}));
client.onVideoData((data) => {
decoder.feedData(data);
});
client.onClipboardChange(content => {
window.navigator.clipboard.writeText(content);
});
const options = new ScrcpyOptions1_22({
logLevel: ScrcpyLogLevel.Debug,
maxSize: this.resolution,
@ -489,16 +465,43 @@ class ScrcpyPageState {
});
runInAction(() => {
this.log = [];
this.log.push(`[client] Server version: ${SCRCPY_SERVER_VERSION}`);
this.log.push(`[client] Server arguments: ${options.formatServerArguments().join(' ')}`);
});
await client.start(
const client = await ScrcpyClient.start(
globalState.device,
DEFAULT_SERVER_PATH,
SCRCPY_SERVER_VERSION,
options,
options
);
client.stdout.pipeTo(new WritableStream({
write: (line) => {
this.log.push(line);
}
}));
client.close().then(() => this.stop());
client.videoStream.pipeThrough(new TransformStream({
transform: (chunk, controller) => {
if (chunk.type === 'configuration') {
const { croppedWidth, croppedHeight, } = chunk.data;
this.log.push(`[client] Video size changed: ${croppedWidth}x${croppedHeight}`);
this.width = croppedWidth;
this.height = croppedHeight;
}
controller.enqueue(chunk);
}
})).pipeTo(decoder.writable);
client.onClipboardChange(content => {
window.navigator.clipboard.writeText(content);
});
runInAction(() => {
this.client = client;
this.running = true;

View file

@ -53,7 +53,7 @@ const Shell: NextPage = (): JSX.Element | null => {
try {
connectingRef.current = true;
const socket = await globalState.device.childProcess.shell();
const socket = await globalState.device.subprocess.shell();
terminal.socket = socket;
} catch (e) {
globalState.showErrorDialog(e instanceof Error ? e.message : `${e}`);

File diff suppressed because it is too large Load diff

View file

@ -37,7 +37,6 @@
},
"dependencies": {
"@yume-chan/adb": "^0.0.10",
"@yume-chan/event": "^0.0.10",
"tslib": "^2.3.1"
}
}

View file

@ -1,5 +1,20 @@
import { AdbBackend, ReadableStream, ReadableWritablePair, TransformStream, WritableStream } from '@yume-chan/adb';
/**
* Transform an `ArrayBufferView` stream to an `ArrayBuffer` stream.
*
* The view must wrap the whole buffer (`byteOffset === 0` && `byteLength === buffer.byteLength`).
*/
export class ExtractViewBufferStream extends TransformStream<ArrayBufferView, ArrayBuffer>{
constructor() {
super({
transform(chunk, controller) {
controller.enqueue(chunk.buffer);
}
});
}
}
declare global {
interface TCPSocket {
close(): Promise<void>;
@ -32,13 +47,7 @@ declare global {
export class AdbDirectSocketsBackendStreams implements ReadableWritablePair<ArrayBuffer, ArrayBuffer>{
private socket: TCPSocket;
private _readableTransformStream = new TransformStream<Uint8Array, ArrayBuffer>({
transform(chunk, controller) {
// Although spec didn't say,
// the chunk always has `byteOffset` of 0 and `byteLength` same as its buffer
controller.enqueue(chunk.buffer);
},
});
private _readableTransformStream: ExtractViewBufferStream;
public get readable(): ReadableStream<ArrayBuffer> {
return this._readableTransformStream.readable;
}
@ -49,6 +58,10 @@ export class AdbDirectSocketsBackendStreams implements ReadableWritablePair<Arra
constructor(socket: TCPSocket) {
this.socket = socket;
// Although Direct Sockets spec didn't say,
// WebTransport spec and File spec all have the `Uint8Array` wraps the while `ArrayBuffer`.
this._readableTransformStream = new ExtractViewBufferStream();
this.socket.readable.pipeTo(this._readableTransformStream.writable);
}
}

View file

@ -33,7 +33,6 @@
"dependencies": {
"@types/w3c-web-usb": "^1.0.4",
"@yume-chan/adb": "^0.0.10",
"@yume-chan/event": "^0.0.10",
"tslib": "^2.3.1"
},
"devDependencies": {

View file

@ -36,8 +36,6 @@
},
"dependencies": {
"@yume-chan/adb": "^0.0.10",
"@yume-chan/async": "^2.1.4",
"@yume-chan/event": "^0.0.10",
"tslib": "^2.3.1"
}
}

View file

@ -1,5 +1,4 @@
import { AdbBackend, ReadableStream, WritableStream } from '@yume-chan/adb';
import { PromiseResolver } from '@yume-chan/async';
export default class AdbWsBackend implements AdbBackend {
public readonly serial: string;
@ -15,12 +14,12 @@ export default class AdbWsBackend implements AdbBackend {
const socket = new WebSocket(this.serial);
socket.binaryType = "arraybuffer";
const resolver = new PromiseResolver();
socket.onopen = resolver.resolve;
await new Promise((resolve, reject) => {
socket.onopen = resolve;
socket.onerror = () => {
resolver.reject(new Error('WebSocket connect failed'));
reject(new Error('WebSocket connect failed'));
};
await resolver.promise;
});
const readable = new ReadableStream({
start: (controller) => {

View file

@ -1,6 +1,6 @@
// cspell: ignore RSASSA
import { AdbCredentialStore, calculateBase64EncodedLength, calculatePublicKey, calculatePublicKeyLength, decodeBase64, encodeBase64 } from "@yume-chan/adb";
import { type AdbCredentialStore, calculateBase64EncodedLength, calculatePublicKey, calculatePublicKeyLength, decodeBase64, encodeBase64 } from "@yume-chan/adb";
const Utf8Encoder = new TextEncoder();
const Utf8Decoder = new TextDecoder();

View file

@ -3,33 +3,12 @@ import { Adb } from '../../adb';
import { AdbFeatures } from '../../features';
import { AdbSocket } from '../../socket';
import { AdbBufferedStream } from '../../stream';
import { AutoResetEvent, QueuingStrategy, ReadableStream, TransformStream, WritableStream, WritableStreamDefaultWriter } from '../../utils';
import { AutoResetEvent, ReadableStream, TransformStream, WritableStream, WritableStreamDefaultWriter } from '../../utils';
import { AdbSyncEntryResponse, adbSyncOpenDir } from './list';
import { adbSyncPull } from './pull';
import { adbSyncPush } from './push';
import { adbSyncLstat, adbSyncStat } from './stat';
class GuardedStream<T> extends TransformStream<T, T>{
constructor(
lock: AutoResetEvent,
writableStrategy?: QueuingStrategy<T>,
readableStrategy?: QueuingStrategy<T>
) {
super(
{
start() {
return lock.wait();
},
flush() {
lock.notify();
},
},
writableStrategy,
readableStrategy
);
}
}
export class AdbSync extends AutoDisposable {
protected adb: Adb;
@ -104,17 +83,47 @@ export class AdbSync extends AutoDisposable {
return results;
}
public read(filename: string): ReadableStream<ArrayBuffer> {
/**
* Read the content of a file on device.
*
* @param filename The full path of the file on device to read.
* @returns
* A promise that resolves to a `ReadableStream`.
*
* If the promise doesn't resolve immediately, it means the sync object is busy processing another command.
*/
public async read(filename: string): Promise<ReadableStream<ArrayBuffer>> {
await this.sendLock.wait();
const readable = adbSyncPull(this.stream, this.writer, filename);
return readable.pipeThrough(new GuardedStream(this.sendLock));
const lockStream = new TransformStream<ArrayBuffer, ArrayBuffer>();
readable
.pipeTo(lockStream.writable)
.then(() => {
this.sendLock.notify();
});
return lockStream.readable;
}
public write(
/**
* Write (or overwrite) a file on device.
*
* @param filename The full path of the file on device to write.
* @param mode The unix permissions of the file.
* @param mtime The modified time of the file.
* @returns
* A promise that resolves to a `WritableStream`.
*
* If the promise doesn't resolve immediately, it means the sync object is busy processing another command.
*/
public async write(
filename: string,
mode?: number,
mtime?: number,
): WritableStream<ArrayBuffer> {
const lockStream = new GuardedStream<ArrayBuffer>(this.sendLock);
): Promise<WritableStream<ArrayBuffer>> {
await this.sendLock.wait();
const writable = adbSyncPush(
this.stream,
@ -123,14 +132,22 @@ export class AdbSync extends AutoDisposable {
mode,
mtime,
);
lockStream.readable.pipeTo(writable);
const lockStream = new TransformStream<ArrayBuffer, ArrayBuffer>();
// `lockStream`'s `flush` will be invoked before `writable` fully closes,
// but `lockStream.readable.pipeTo` will wait for `writable` to close.
lockStream.readable
.pipeTo(writable)
.then(() => {
this.sendLock.notify();
});
return lockStream.writable;
}
public override dispose() {
public override async dispose() {
super.dispose();
this.stream.close();
this.writer.close();
await this.writer.close();
}
}

View file

@ -35,7 +35,6 @@
},
"dependencies": {
"@yume-chan/adb": "^0.0.10",
"@yume-chan/event": "^0.0.10",
"tslib": "^2.3.1"
}
}

View file

@ -1,9 +1,9 @@
import { Adb, AdbBufferedStream, AdbNoneSubprocessProtocol, AdbSocket, AdbSubprocessProtocol, DecodeUtf8Stream, ReadableStream, TransformStream, WritableStreamDefaultWriter } from '@yume-chan/adb';
import { Adb, AdbBufferedStream, AdbNoneSubprocessProtocol, AdbSocket, AdbSubprocessProtocol, DecodeUtf8Stream, TransformStream, WritableStreamDefaultWriter } from '@yume-chan/adb';
import { EventEmitter } from '@yume-chan/event';
import Struct from '@yume-chan/struct';
import { AndroidMotionEventAction, ScrcpyControlMessageType, ScrcpyInjectKeyCodeControlMessage, ScrcpyInjectTextControlMessage, ScrcpyInjectTouchControlMessage, type AndroidKeyEventAction } from './message';
import type { ScrcpyInjectScrollControlMessage1_22, ScrcpyOptions, VideoStreamPacket } from "./options";
import { pushServer, PushServerOptions } from "./push-server";
import { PushServerOptions, pushServerStream } from "./push-server";
function* splitLines(text: string): Generator<string, void, void> {
let start = 0;
@ -27,12 +27,11 @@ const ClipboardMessage =
.string('content', { lengthField: 'length' });
export class ScrcpyClient {
public static pushServer(
public static pushServerStream(
device: Adb,
file: ReadableStream<ArrayBuffer>,
options?: PushServerOptions
) {
pushServer(device, file, options);
return pushServerStream(device, options);
}
public static async getEncoders(
@ -128,7 +127,7 @@ export class ScrcpyClient {
public get screenHeight() { return this._screenHeight; }
private _videoStream: TransformStream<VideoStreamPacket, VideoStreamPacket>;
public get videoStream() { return this._videoStream; }
public get videoStream() { return this._videoStream.readable; }
private _controlStreamWriter: WritableStreamDefaultWriter<ArrayBuffer> | undefined;

View file

@ -1,6 +1,7 @@
import type { WritableStream } from '@yume-chan/adb';
import type { Disposable } from "@yume-chan/event";
import type { AndroidCodecLevel, AndroidCodecProfile } from "../codec";
import type { VideoStreamPacket } from "../options";
export interface H264Configuration {
profileIndex: number;
@ -27,9 +28,7 @@ export interface H264Decoder extends Disposable {
readonly renderer: HTMLElement;
readonly writable: WritableStream<ArrayBuffer>;
configure(config: H264Configuration): void;
readonly writable: WritableStream<VideoStreamPacket>;
}
export interface H264DecoderConstructor {

View file

@ -1,8 +1,9 @@
import { WritableStream } from "@yume-chan/adb";
import { PromiseResolver } from "@yume-chan/async";
import { AndroidCodecLevel, AndroidCodecProfile } from "../../codec";
import type { VideoStreamPacket } from "../../options";
import type { H264Configuration, H264Decoder } from '../common';
import { createTinyH264Wrapper, TinyH264Wrapper } from "./wrapper";
import { createTinyH264Wrapper, type TinyH264Wrapper } from "./wrapper";
let cachedInitializePromise: Promise<{ YuvBuffer: typeof import('yuv-buffer'), YuvCanvas: typeof import('yuv-canvas').default; }> | undefined;
function initialize() {
@ -25,16 +26,7 @@ export class TinyH264Decoder implements H264Decoder {
private _renderer: HTMLCanvasElement;
public get renderer() { return this._renderer; }
private _writable = new WritableStream<ArrayBuffer>({
write: async (chunk) => {
if (!this._initializer) {
throw new Error('Decoder not initialized');
}
const wrapper = await this._initializer.promise;
wrapper.feed(chunk);
}
});
private _writable: WritableStream<VideoStreamPacket>;
public get writable() { return this._writable; }
private _yuvCanvas: import('yuv-canvas').default | undefined;
@ -42,10 +34,29 @@ export class TinyH264Decoder implements H264Decoder {
public constructor() {
initialize();
this._renderer = document.createElement('canvas');
this._writable = new WritableStream({
write: async (packet) => {
switch (packet.type) {
case 'configuration':
this.configure(packet.data);
break;
case 'frame':
if (!this._initializer) {
throw new Error('Decoder not initialized');
}
public async configure(config: H264Configuration) {
const wrapper = await this._initializer.promise;
wrapper.feed(packet.data);
break;
}
}
});
}
private async configure(config: H264Configuration) {
this.dispose();
this._initializer = new PromiseResolver<TinyH264Wrapper>();

View file

@ -1,5 +1,5 @@
import type { ValueOrPromise } from "@yume-chan/struct";
import { AndroidCodecLevel, AndroidCodecProfile } from "../../codec";
import type { VideoStreamPacket } from "../../options";
import type { H264Configuration, H264Decoder } from "../common";
function toHex(value: number) {
@ -11,15 +11,7 @@ export class WebCodecsDecoder implements H264Decoder {
public readonly maxLevel = AndroidCodecLevel.Level5;
private _writable = new WritableStream<ArrayBuffer>({
write: async (chunk) => {
this.decoder.decode(new EncodedVideoChunk({
type: 'key',
timestamp: 0,
data: chunk,
}));
}
});
private _writable: WritableStream<VideoStreamPacket>;
public get writable() { return this._writable; }
private _renderer: HTMLCanvasElement;
@ -39,9 +31,26 @@ export class WebCodecsDecoder implements H264Decoder {
},
error() { },
});
this._writable = new WritableStream({
write: async (packet) => {
switch (packet.type) {
case 'configuration':
this.configure(packet.data);
break;
case 'frame':
this.decoder.decode(new EncodedVideoChunk({
type: 'key',
timestamp: 0,
data: packet.data,
}));
break;
}
}
});
}
public configure(config: H264Configuration): ValueOrPromise<void> {
private configure(config: H264Configuration) {
const { profileIndex, constraintSet, levelIndex } = config;
this._renderer.width = config.croppedWidth;
@ -56,14 +65,6 @@ export class WebCodecsDecoder implements H264Decoder {
});
}
feedData(data: ArrayBuffer): ValueOrPromise<void> {
this.decoder.decode(new EncodedVideoChunk({
type: 'key',
timestamp: 0,
data,
}));
}
public dispose() {
this.decoder.close();
}

View file

@ -1,13 +1,12 @@
import { Adb } from "@yume-chan/adb";
import { Adb, TransformStream } from "@yume-chan/adb";
import { DEFAULT_SERVER_PATH } from "./options";
export interface PushServerOptions {
path?: string;
}
export async function pushServer(
export async function pushServerStream(
device: Adb,
file: ReadableStream<ArrayBuffer>,
options: PushServerOptions = {}
) {
const {
@ -15,6 +14,16 @@ export async function pushServer(
} = options;
const sync = await device.sync();
const stream = sync.write(path);
await file.pipeTo(stream);
const writable = sync.write(path);
const lockStream = new TransformStream<ArrayBuffer, ArrayBuffer>();
// Same as inside AdbSync,
// can only use `pipeTo` to detect the writable is fully closed.
lockStream.readable
.pipeTo(writable)
.then(() => {
sync.dispose();
});
return lockStream.writable;
}

View file

@ -42,7 +42,6 @@
"typescript": "^4.5.5",
"@yume-chan/ts-package-builder": "^1.0.0",
"@types/jest": "^27.4.0",
"@types/node": "^17.0.17",
"@types/bluebird": "^3.5.36"
}
}