diff --git a/package-lock.json b/package-lock.json
index 4e2dc88aa5..c721f24c16 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,6 +7,12 @@
     "": {
       "name": "misskey-js",
       "version": "0.0.0",
+      "dependencies": {
+        "@vue/reactivity": "^3.0.11",
+        "autobind-decorator": "^2.4.0",
+        "eventemitter3": "^4.0.7",
+        "reconnecting-websocket": "^4.4.0"
+      },
       "devDependencies": {
         "@types/mocha": "8.2.x",
         "@types/node": "14.14.x",
@@ -199,6 +205,19 @@
       "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==",
       "dev": true
     },
+    "node_modules/@vue/reactivity": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.0.11.tgz",
+      "integrity": "sha512-SKM3YKxtXHBPMf7yufXeBhCZ4XZDKP9/iXeQSC8bBO3ivBuzAi4aZi0bNoeE2IF2iGfP/AHEt1OU4ARj4ao/Xw==",
+      "dependencies": {
+        "@vue/shared": "3.0.11"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.0.11.tgz",
+      "integrity": "sha512-b+zB8A2so8eCE0JsxjL24J7vdGl8rzPQ09hZNhystm+KqSbKcAej1A+Hbva1rCMmTTqA+hFnUSDc5kouEo0JzA=="
+    },
     "node_modules/ansi-align": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz",
@@ -349,6 +368,15 @@
         "node": ">=0.10.0"
       }
     },
+    "node_modules/autobind-decorator": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/autobind-decorator/-/autobind-decorator-2.4.0.tgz",
+      "integrity": "sha512-OGYhWUO72V6DafbF8PM8rm3EPbfuyMZcJhtm5/n26IDwO18pohE4eNazLoCGhPiXOCD0gEGmrbU3849QvM8bbw==",
+      "engines": {
+        "node": ">=8.10",
+        "npm": ">=6.4.1"
+      }
+    },
     "node_modules/balanced-match": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -866,6 +894,11 @@
       "integrity": "sha512-Wnn0ETzE2v2UT0OdRCcdMNPkQtbzyZr3pPPXnkreP0l6ZJaKqnl88dL1DqZ6nCCZZwDGBAnN0Y+nCvGxxLPQLQ==",
       "dev": true
     },
+    "node_modules/eventemitter3": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
+    },
     "node_modules/fast-glob": {
       "version": "3.2.5",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz",
@@ -2082,6 +2115,11 @@
         "node": ">=8.10.0"
       }
     },
+    "node_modules/reconnecting-websocket": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz",
+      "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng=="
+    },
     "node_modules/redent": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
@@ -3006,6 +3044,19 @@
       "integrity": "sha512-sL/cEvJWAnClXw0wHk85/2L0G6Sj8UB0Ctc1TEMbKSsmpRosqhwj9gWgFRZSrBr2f9tiXISwNhCPmlfqUqyb9Q==",
       "dev": true
     },
+    "@vue/reactivity": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.0.11.tgz",
+      "integrity": "sha512-SKM3YKxtXHBPMf7yufXeBhCZ4XZDKP9/iXeQSC8bBO3ivBuzAi4aZi0bNoeE2IF2iGfP/AHEt1OU4ARj4ao/Xw==",
+      "requires": {
+        "@vue/shared": "3.0.11"
+      }
+    },
+    "@vue/shared": {
+      "version": "3.0.11",
+      "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.0.11.tgz",
+      "integrity": "sha512-b+zB8A2so8eCE0JsxjL24J7vdGl8rzPQ09hZNhystm+KqSbKcAej1A+Hbva1rCMmTTqA+hFnUSDc5kouEo0JzA=="
+    },
     "ansi-align": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.0.tgz",
@@ -3119,6 +3170,11 @@
       "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=",
       "dev": true
     },
+    "autobind-decorator": {
+      "version": "2.4.0",
+      "resolved": "https://registry.npmjs.org/autobind-decorator/-/autobind-decorator-2.4.0.tgz",
+      "integrity": "sha512-OGYhWUO72V6DafbF8PM8rm3EPbfuyMZcJhtm5/n26IDwO18pohE4eNazLoCGhPiXOCD0gEGmrbU3849QvM8bbw=="
+    },
     "balanced-match": {
       "version": "1.0.2",
       "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -3525,6 +3581,11 @@
       "integrity": "sha512-Wnn0ETzE2v2UT0OdRCcdMNPkQtbzyZr3pPPXnkreP0l6ZJaKqnl88dL1DqZ6nCCZZwDGBAnN0Y+nCvGxxLPQLQ==",
       "dev": true
     },
+    "eventemitter3": {
+      "version": "4.0.7",
+      "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
+      "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="
+    },
     "fast-glob": {
       "version": "3.2.5",
       "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.2.5.tgz",
@@ -4424,6 +4485,11 @@
         "picomatch": "^2.2.1"
       }
     },
+    "reconnecting-websocket": {
+      "version": "4.4.0",
+      "resolved": "https://registry.npmjs.org/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz",
+      "integrity": "sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng=="
+    },
     "redent": {
       "version": "3.0.0",
       "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
diff --git a/package.json b/package.json
index b7be912af3..d982700716 100644
--- a/package.json
+++ b/package.json
@@ -24,5 +24,11 @@
   },
   "files": [
     "built"
-  ]
+  ],
+  "dependencies": {
+    "@vue/reactivity": "^3.0.11",
+    "autobind-decorator": "^2.4.0",
+    "eventemitter3": "^4.0.7",
+    "reconnecting-websocket": "^4.4.0"
+  }
 }
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000000..b24d090c77
--- /dev/null
+++ b/src/api.ts
@@ -0,0 +1,42 @@
+import { Endpoints } from './endpoints';
+
+export class MisskeyClient {
+	public i: { token: string; } | null = null;
+	private apiUrl: string;
+
+	constructor(opts: {
+		apiUrl: MisskeyClient['apiUrl'];
+	}) {
+		this.apiUrl = opts.apiUrl;
+	}
+
+	public api<E extends keyof Endpoints>(
+		endpoint: E, data: Endpoints[E]['req'] = {}, token?: string | null | undefined
+	): Promise<Endpoints[E]['res']> {
+		const promise = new Promise<Endpoints[E]['res']>((resolve, reject) => {
+			// Append a credential
+			if (this.i) (data as Record<string, any>).i = this.i.token;
+			if (token !== undefined) (data as Record<string, any>).i = token;
+	
+			// Send request
+			fetch(endpoint.indexOf('://') > -1 ? endpoint : `${this.apiUrl}/${endpoint}`, {
+				method: 'POST',
+				body: JSON.stringify(data),
+				credentials: 'omit',
+				cache: 'no-cache'
+			}).then(async (res) => {
+				const body = res.status === 204 ? null : await res.json();
+	
+				if (res.status === 200) {
+					resolve(body);
+				} else if (res.status === 204) {
+					resolve(null);
+				} else {
+					reject(body.error);
+				}
+			}).catch(reject);
+		});
+	
+		return promise;
+	}
+}
diff --git a/src/endpoints.ts b/src/endpoints.ts
new file mode 100644
index 0000000000..f1f141e4df
--- /dev/null
+++ b/src/endpoints.ts
@@ -0,0 +1,8 @@
+import { Instance, User } from './types';
+
+type TODO = Record<string, any>;
+
+export type Endpoints = {
+	'i': { req: TODO; res: User; };
+	'meta': { req: { detail?: boolean; }; res: Instance; };
+};
diff --git a/src/streaming.ts b/src/streaming.ts
new file mode 100644
index 0000000000..be228ac386
--- /dev/null
+++ b/src/streaming.ts
@@ -0,0 +1,322 @@
+import autobind from 'autobind-decorator';
+import { EventEmitter } from 'eventemitter3';
+import ReconnectingWebsocket from 'reconnecting-websocket';
+import { stringify } from 'querystring';
+import { markRaw } from '@vue/reactivity';
+
+function urlQuery(obj: {}): string {
+	return stringify(Object.entries(obj)
+		.filter(([, v]) => Array.isArray(v) ? v.length : v !== undefined)
+		.reduce((a, [k, v]) => (a[k] = v, a), {} as Record<string, any>));
+}
+
+/**
+ * Misskey stream connection
+ */
+export default class Stream extends EventEmitter {
+	private stream: ReconnectingWebsocket;
+	public state: 'initializing' | 'reconnecting' | 'connected' = 'initializing';
+	private sharedConnectionPools: Pool[] = [];
+	private sharedConnections: SharedConnection[] = [];
+	private nonSharedConnections: NonSharedConnection[] = [];
+
+	constructor(wsUrl: string, user: { token: string; } | null, options?: {
+	}) {
+		super();
+
+		const query = urlQuery({
+			i: user?.token,
+			_t: Date.now(),
+		});
+
+		this.stream = new ReconnectingWebsocket(`${wsUrl}?${query}`, '', { minReconnectionDelay: 1 }); // https://github.com/pladaria/reconnecting-websocket/issues/91
+		this.stream.addEventListener('open', this.onOpen);
+		this.stream.addEventListener('close', this.onClose);
+		this.stream.addEventListener('message', this.onMessage);
+	}
+
+	@autobind
+	public useSharedConnection(channel: string, name?: string): SharedConnection {
+		let pool = this.sharedConnectionPools.find(p => p.channel === channel);
+
+		if (pool == null) {
+			pool = new Pool(this, channel);
+			this.sharedConnectionPools.push(pool);
+		}
+
+		const connection = markRaw(new SharedConnection(this, channel, pool, name));
+		this.sharedConnections.push(connection);
+		return connection;
+	}
+
+	@autobind
+	public removeSharedConnection(connection: SharedConnection) {
+		this.sharedConnections = this.sharedConnections.filter(c => c !== connection);
+	}
+
+	@autobind
+	public removeSharedConnectionPool(pool: Pool) {
+		this.sharedConnectionPools = this.sharedConnectionPools.filter(p => p !== pool);
+	}
+
+	@autobind
+	public connectToChannel(channel: string, params?: any): NonSharedConnection {
+		const connection = markRaw(new NonSharedConnection(this, channel, params));
+		this.nonSharedConnections.push(connection);
+		return connection;
+	}
+
+	@autobind
+	public disconnectToChannel(connection: NonSharedConnection) {
+		this.nonSharedConnections = this.nonSharedConnections.filter(c => c !== connection);
+	}
+
+	/**
+	 * Callback of when open connection
+	 */
+	@autobind
+	private onOpen() {
+		const isReconnect = this.state === 'reconnecting';
+
+		this.state = 'connected';
+		this.emit('_connected_');
+
+		// チャンネル再接続
+		if (isReconnect) {
+			for (const p of this.sharedConnectionPools)
+				p.connect();
+			for (const c of this.nonSharedConnections)
+				c.connect();
+		}
+	}
+
+	/**
+	 * Callback of when close connection
+	 */
+	@autobind
+	private onClose() {
+		if (this.state === 'connected') {
+			this.state = 'reconnecting';
+			this.emit('_disconnected_');
+		}
+	}
+
+	/**
+	 * Callback of when received a message from connection
+	 */
+	@autobind
+	private onMessage(message: { data: string; }) {
+		const { type, body } = JSON.parse(message.data);
+
+		if (type === 'channel') {
+			const id = body.id;
+
+			let connections: Connection[];
+
+			connections = this.sharedConnections.filter(c => c.id === id);
+
+			if (connections.length === 0) {
+				const found = this.nonSharedConnections.find(c => c.id === id);
+				if (found) {
+					connections = [found];
+				}
+			}
+
+			for (const c of connections.filter(c => c != null)) {
+				c.emit(body.type, Object.freeze(body.body));
+				c.inCount++;
+			}
+		} else {
+			this.emit(type, Object.freeze(body));
+		}
+	}
+
+	/**
+	 * Send a message to connection
+	 */
+	@autobind
+	public send(typeOrPayload: any, payload?: any) {
+		const data = payload === undefined ? typeOrPayload : {
+			type: typeOrPayload,
+			body: payload
+		};
+
+		this.stream.send(JSON.stringify(data));
+	}
+
+	/**
+	 * Close this connection
+	 */
+	@autobind
+	public close() {
+		this.stream.removeEventListener('open', this.onOpen);
+		this.stream.removeEventListener('message', this.onMessage);
+	}
+}
+
+let idCounter = 0;
+
+class Pool {
+	public channel: string;
+	public id: string;
+	protected stream: Stream;
+	public users = 0;
+	private disposeTimerId: any;
+	private isConnected = false;
+
+	constructor(stream: Stream, channel: string) {
+		this.channel = channel;
+		this.stream = stream;
+
+		this.id = (++idCounter).toString();
+
+		this.stream.on('_disconnected_', this.onStreamDisconnected);
+	}
+
+	@autobind
+	private onStreamDisconnected() {
+		this.isConnected = false;
+	}
+
+	@autobind
+	public inc() {
+		if (this.users === 0 && !this.isConnected) {
+			this.connect();
+		}
+
+		this.users++;
+
+		// タイマー解除
+		if (this.disposeTimerId) {
+			clearTimeout(this.disposeTimerId);
+			this.disposeTimerId = null;
+		}
+	}
+
+	@autobind
+	public dec() {
+		this.users--;
+
+		// そのコネクションの利用者が誰もいなくなったら
+		if (this.users === 0) {
+			// また直ぐに再利用される可能性があるので、一定時間待ち、
+			// 新たな利用者が現れなければコネクションを切断する
+			this.disposeTimerId = setTimeout(() => {
+				this.disconnect();
+			}, 3000);
+		}
+	}
+
+	@autobind
+	public connect() {
+		if (this.isConnected) return;
+		this.isConnected = true;
+		this.stream.send('connect', {
+			channel: this.channel,
+			id: this.id
+		});
+	}
+
+	@autobind
+	private disconnect() {
+		this.stream.off('_disconnected_', this.onStreamDisconnected);
+		this.stream.send('disconnect', { id: this.id });
+		this.stream.removeSharedConnectionPool(this);
+	}
+}
+
+abstract class Connection extends EventEmitter {
+	public channel: string;
+	protected stream: Stream;
+	public abstract id: string;
+
+	public name?: string; // for debug
+	public inCount: number = 0; // for debug
+	public outCount: number = 0; // for debug
+
+	constructor(stream: Stream, channel: string, name?: string) {
+		super();
+
+		this.stream = stream;
+		this.channel = channel;
+		this.name = name;
+	}
+
+	@autobind
+	public send(id: string, typeOrPayload: any, payload?: any) {
+		const type = payload === undefined ? typeOrPayload.type : typeOrPayload;
+		const body = payload === undefined ? typeOrPayload.body : payload;
+
+		this.stream.send('ch', {
+			id: id,
+			type: type,
+			body: body
+		});
+
+		this.outCount++;
+	}
+
+	public abstract dispose(): void;
+}
+
+class SharedConnection extends Connection {
+	private pool: Pool;
+
+	public get id(): string {
+		return this.pool.id;
+	}
+
+	constructor(stream: Stream, channel: string, pool: Pool, name?: string) {
+		super(stream, channel, name);
+
+		this.pool = pool;
+		this.pool.inc();
+	}
+
+	@autobind
+	public send(typeOrPayload: any, payload?: any) {
+		super.send(this.pool.id, typeOrPayload, payload);
+	}
+
+	@autobind
+	public dispose() {
+		this.pool.dec();
+		this.removeAllListeners();
+		this.stream.removeSharedConnection(this);
+	}
+}
+
+class NonSharedConnection extends Connection {
+	public id: string;
+	protected params: any;
+
+	constructor(stream: Stream, channel: string, params?: any) {
+		super(stream, channel);
+
+		this.params = params;
+		this.id = (++idCounter).toString();
+
+		this.connect();
+	}
+
+	@autobind
+	public connect() {
+		this.stream.send('connect', {
+			channel: this.channel,
+			id: this.id,
+			params: this.params
+		});
+	}
+
+	@autobind
+	public send(typeOrPayload: any, payload?: any) {
+		super.send(this.id, typeOrPayload, payload);
+	}
+
+	@autobind
+	public dispose() {
+		this.removeAllListeners();
+		this.stream.send('disconnect', { id: this.id });
+		this.stream.disconnectToChannel(this);
+	}
+}
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000000..7dd671d315
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,73 @@
+type ID = string;
+
+export type User = {
+	id: ID;
+	username: string;
+	host: string | null;
+	name: string;
+	onlineStatus: 'online' | 'active' | 'offline' | 'unknown';
+	avatarUrl: string;
+	avatarBlurhash: string;
+	emojis: {
+		name: string;
+		url: string;
+	}[];
+};
+
+export type DriveFile = {
+	id: ID;
+	createdAt: string;
+	isSensitive: boolean;
+	name: string;
+	thumbnailUrl: string;
+	url: string;
+	type: string;
+	size: number;
+	md5: string;
+	blurhash: string;
+	properties: Record<string, any>;
+};
+
+export type Note = {
+	id: ID;
+	createdAt: string;
+	text: string | null;
+	cw: string | null;
+	user: User;
+	userId: User['id'];
+	reply?: Note;
+	replyId: Note['id'];
+	renote?: Note;
+	renoteId: Note['id'];
+	files: DriveFile[];
+	fileIds: DriveFile['id'][];
+	visibility: 'public' | 'home' | 'followers' | 'specified';
+	myReaction?: string;
+	reactions: Record<string, number>;
+	poll?: {
+		expiresAt: string | null;
+		multiple: boolean;
+		choices: {
+			isVoted: boolean;
+			text: string;
+			votes: number;
+		}[];
+	};
+	emojis: {
+		name: string;
+		url: string;
+	}[];
+};
+
+export type Instance = {
+	emojis: {
+		category: string;
+	}[];
+	ads: {
+		id: ID;
+		ratio: number;
+		place: string;
+		url: string;
+		imageUrl: string;
+	}[];
+};
diff --git a/tsconfig.json b/tsconfig.json
index e9859ec768..f882a54c73 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -8,6 +8,8 @@
     "removeComments": true,
     "strict": true,
     "strictFunctionTypes": true,
+    "strictNullChecks": true,
+    "experimentalDecorators": true,
     "noImplicitReturns": true,
     "esModuleInterop": true,
   },