diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 9da27f4678..4e42fa9314 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -16,7 +16,7 @@ jobs:
         submodules: true
     - uses: actions/setup-node@v3
       with:
-        node-version: 16.x
+        node-version: 18.x
         cache: 'yarn'
         cache-dependency-path: |
           packages/backend/yarn.lock
@@ -31,7 +31,7 @@ jobs:
         submodules: true
     - uses: actions/setup-node@v3
       with:
-        node-version: 16.x
+        node-version: 18.x
         cache: 'yarn'
         cache-dependency-path: |
           packages/client/yarn.lock
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index d57d85c874..2d858daa7c 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,7 +13,7 @@ jobs:
 
     strategy:
       matrix:
-        node-version: [16.x]
+        node-version: [18.x]
 
     services:
       postgres:
@@ -57,7 +57,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        node-version: [16.x]
+        node-version: [18.x]
         browser: [chrome]
 
     services:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index ac5dd06312..8842014a20 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,7 +2,9 @@
 ## 12.x.x (unreleased)
 
 ### Improvements
-- 
+- API: notifications/readは配列でも受け付けるように
+- /share のクエリでリプライやファイル等の情報を渡せるように
+- ページロードエラーページにリロードボタンを追加
 
 ### Bugfixes
 - 
@@ -20,15 +22,19 @@ You should also include the user name that made the change.
 
 ### Improvements
 - Client: Preferences Registry
+- enhance: ドライブに画像ファイルをアップロードするときオリジナル画像を破棄してwebpublicのみ保持するオプション @tamaina
 
 ### Bugfixes
-- 
+- Client: fix settings page @tamaina
+- Client: fix profile tabs @futchitwo
+- Server: await promises when following or unfollowing users @Johann150
+- Client: fix abuse reports page to be able to show all reports @Johann150
 
 ## 12.110.1 (2022/04/23)
 
 ### Bugfixes
 - Fix GOP rendering @syuilo
-- Improve performance of antenna, clip, and list @xianon
+- Improve performance of antenna, clip, and list @xianonn
 
 ## 12.110.0 (2022/04/11)
 
diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml
index a52c67d34b..10e64ba555 100644
--- a/locales/ja-JP.yml
+++ b/locales/ja-JP.yml
@@ -356,7 +356,7 @@ antennaExcludeKeywords: "除外キーワード"
 antennaKeywordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります"
 notifyAntenna: "新しいノートを通知する"
 withFileAntenna: "ファイルが添付されたノートのみ"
-enableServiceworker: "ServiceWorkerを有効にする"
+enableServiceworker: "ブラウザへのプッシュ通知を有効にする"
 antennaUsersDescription: "ユーザー名を改行で区切って指定します"
 caseSensitive: "大文字小文字を区別する"
 withReplies: "返信を含む"
@@ -1693,8 +1693,9 @@ _notification:
   youWereFollowed: "フォローされました"
   youReceivedFollowRequest: "フォローリクエストが来ました"
   yourFollowRequestAccepted: "フォローリクエストが承認されました"
-  youWereInvitedToGroup: "グループに招待されました"
+  youWereInvitedToGroup: "{userName}があなたをグループに招待しました"
   pollEnded: "アンケートの結果が出ました"
+  emptyPushNotificationMessage: "プッシュ通知の更新をしました"
 
   _types:
     all: "すべて"
@@ -1711,6 +1712,11 @@ _notification:
     groupInvited: "グループに招待された"
     app: "連携アプリからの通知"
 
+  _actions:
+    followBack: "フォローバック"
+    reply: "返信"
+    renote: "Renote"
+
 _deck:
   alwaysShowMainColumn: "常にメインカラムを表示"
   columnAlign: "カラムの寄せ"
diff --git a/packages/backend/src/server/api/common/read-messaging-message.ts b/packages/backend/src/server/api/common/read-messaging-message.ts
index 3638518e67..c4c18ffa06 100644
--- a/packages/backend/src/server/api/common/read-messaging-message.ts
+++ b/packages/backend/src/server/api/common/read-messaging-message.ts
@@ -1,6 +1,7 @@
 import { publishMainStream, publishGroupMessagingStream } from '@/services/stream.js';
 import { publishMessagingStream } from '@/services/stream.js';
 import { publishMessagingIndexStream } from '@/services/stream.js';
+import { pushNotification } from '@/services/push-notification.js';
 import { User, IRemoteUser } from '@/models/entities/user.js';
 import { MessagingMessage } from '@/models/entities/messaging-message.js';
 import { MessagingMessages, UserGroupJoinings, Users } from '@/models/index.js';
@@ -50,6 +51,21 @@ export async function readUserMessagingMessage(
 	if (!await Users.getHasUnreadMessagingMessage(userId)) {
 		// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
 		publishMainStream(userId, 'readAllMessagingMessages');
+		pushNotification(userId, 'readAllMessagingMessages', undefined);
+	} else {
+		// そのユーザーとのメッセージで未読がなければイベント発行
+		const count = await MessagingMessages.count({
+			where: {
+				userId: otherpartyId,
+				recipientId: userId,
+				isRead: false,
+			},
+			take: 1
+		});
+
+		if (!count) {
+			pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId });
+		}
 	}
 }
 
@@ -104,6 +120,19 @@ export async function readGroupMessagingMessage(
 	if (!await Users.getHasUnreadMessagingMessage(userId)) {
 		// 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行
 		publishMainStream(userId, 'readAllMessagingMessages');
+		pushNotification(userId, 'readAllMessagingMessages', undefined);
+	} else {
+		// そのグループにおいて未読がなければイベント発行
+		const unreadExist = await MessagingMessages.createQueryBuilder('message')
+			.where(`message.groupId = :groupId`, { groupId: groupId })
+			.andWhere('message.userId != :userId', { userId: userId })
+			.andWhere('NOT (:userId = ANY(message.reads))', { userId: userId })
+			.andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない
+			.getOne().then(x => x != null);
+
+		if (!unreadExist) {
+			pushNotification(userId, 'readAllMessagingMessagesOfARoom', { groupId });
+		}
 	}
 }
 
diff --git a/packages/backend/src/server/api/common/read-notification.ts b/packages/backend/src/server/api/common/read-notification.ts
index 1f575042a0..0dad35bcc2 100644
--- a/packages/backend/src/server/api/common/read-notification.ts
+++ b/packages/backend/src/server/api/common/read-notification.ts
@@ -1,4 +1,5 @@
 import { publishMainStream } from '@/services/stream.js';
+import { pushNotification } from '@/services/push-notification.js';
 import { User } from '@/models/entities/user.js';
 import { Notification } from '@/models/entities/notification.js';
 import { Notifications, Users } from '@/models/index.js';
@@ -16,28 +17,29 @@ export async function readNotification(
 		isRead: true,
 	});
 
-	post(userId);
+	if (!await Users.getHasUnreadNotification(userId)) return postReadAllNotifications(userId);
+	else return postReadNotifications(userId, notificationIds);
 }
 
 export async function readNotificationByQuery(
 	userId: User['id'],
 	query: Record<string, any>
 ) {
-	// Update documents
-	await Notifications.update({
+	const notificationIds = await Notifications.find({
 		...query,
 		notifieeId: userId,
 		isRead: false,
-	}, {
-		isRead: true,
-	});
+	}).then(notifications => notifications.map(notification => notification.id));
 
-	post(userId);
+	return readNotification(userId, notificationIds);
 }
 
-async function post(userId: User['id']) {
-	if (!await Users.getHasUnreadNotification(userId)) {
-		// 全ての(いままで未読だった)通知を(これで)読みましたよというイベントを発行
-		publishMainStream(userId, 'readAllNotifications');
-	}
+function postReadAllNotifications(userId: User['id']) {
+	publishMainStream(userId, 'readAllNotifications');
+	return pushNotification(userId, 'readAllNotifications', undefined);
+}
+
+function postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) {
+	publishMainStream(userId, 'readNotifications', notificationIds);
+	return pushNotification(userId, 'readNotifications', { notificationIds });
 }
diff --git a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts
index abefe07be6..4575cba43f 100644
--- a/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts
+++ b/packages/backend/src/server/api/endpoints/notifications/mark-all-as-read.ts
@@ -1,4 +1,5 @@
 import { publishMainStream } from '@/services/stream.js';
+import { pushNotification } from '@/services/push-notification.js';
 import define from '../../define.js';
 import { Notifications } from '@/models/index.js';
 
@@ -28,4 +29,5 @@ export default define(meta, paramDef, async (ps, user) => {
 
 	// 全ての通知を読みましたよというイベントを発行
 	publishMainStream(user.id, 'readAllNotifications');
+	pushNotification(user.id, 'readAllNotifications', undefined);
 });
diff --git a/packages/backend/src/server/api/endpoints/notifications/read.ts b/packages/backend/src/server/api/endpoints/notifications/read.ts
index c7bc5dc0a5..65e96d4862 100644
--- a/packages/backend/src/server/api/endpoints/notifications/read.ts
+++ b/packages/backend/src/server/api/endpoints/notifications/read.ts
@@ -1,10 +1,12 @@
-import { publishMainStream } from '@/services/stream.js';
 import define from '../../define.js';
-import { Notifications } from '@/models/index.js';
 import { readNotification } from '../../common/read-notification.js';
-import { ApiError } from '../../error.js';
 
 export const meta = {
+	desc: {
+		'ja-JP': '通知を既読にします。',
+		'en-US': 'Mark a notification as read.'
+	},
+
 	tags: ['notifications', 'account'],
 
 	requireCredential: true,
@@ -21,23 +23,26 @@ export const meta = {
 } as const;
 
 export const paramDef = {
-	type: 'object',
-	properties: {
-		notificationId: { type: 'string', format: 'misskey:id' },
-	},
-	required: ['notificationId'],
+	oneOf: [
+		{
+			type: 'object',
+			properties: {
+				notificationId: { type: 'string', format: 'misskey:id' },
+			},
+			required: ['notificationId'],
+		},
+		{
+			type: 'object',
+			properties: {
+				notificationIds: { type: 'array', items: { type: 'string', format: 'misskey:id' } },
+			},
+			required: ['notificationIds'],
+		},
+	],
 } as const;
 
 // eslint-disable-next-line import/no-default-export
 export default define(meta, paramDef, async (ps, user) => {
-	const notification = await Notifications.findOneBy({
-		notifieeId: user.id,
-		id: ps.notificationId,
-	});
-
-	if (notification == null) {
-		throw new ApiError(meta.errors.noSuchNotification);
-	}
-
-	readNotification(user.id, [notification.id]);
+	if ('notificationId' in ps) return readNotification(user.id, [ps.notificationId]);
+	return readNotification(user.id, ps.notificationIds);
 });
diff --git a/packages/backend/src/server/web/index.ts b/packages/backend/src/server/web/index.ts
index e80bf45d14..061ea50609 100644
--- a/packages/backend/src/server/web/index.ts
+++ b/packages/backend/src/server/web/index.ts
@@ -32,6 +32,7 @@ const _dirname = dirname(_filename);
 const staticAssets = `${_dirname}/../../../assets/`;
 const clientAssets = `${_dirname}/../../../../client/assets/`;
 const assets = `${_dirname}/../../../../../built/_client_dist_/`;
+const swAssets = `${_dirname}/../../../../../built/_sw_dist_/`;
 
 // Init app
 const app = new Koa();
@@ -136,9 +137,10 @@ router.get('/twemoji/(.*)', async ctx => {
 });
 
 // ServiceWorker
-router.get('/sw.js', async ctx => {
-	await send(ctx as any, `/sw.${config.version}.js`, {
-		root: assets,
+router.get(`/sw.js`, async ctx => {
+	await send(ctx as any, `/sw.js`, {
+		root: swAssets,
+		maxage: ms('10 minutes'),
 	});
 });
 
diff --git a/packages/backend/src/services/create-notification.ts b/packages/backend/src/services/create-notification.ts
index 9a53db1f38..d53a4235b8 100644
--- a/packages/backend/src/services/create-notification.ts
+++ b/packages/backend/src/services/create-notification.ts
@@ -1,5 +1,5 @@
 import { publishMainStream } from '@/services/stream.js';
-import pushSw from './push-notification.js';
+import { pushNotification } from '@/services/push-notification.js';
 import { Notifications, Mutings, UserProfiles, Users } from '@/models/index.js';
 import { genId } from '@/misc/gen-id.js';
 import { User } from '@/models/entities/user.js';
@@ -52,8 +52,8 @@ export async function createNotification(
 		//#endregion
 
 		publishMainStream(notifieeId, 'unreadNotification', packed);
+		pushNotification(notifieeId, 'notification', packed);
 
-		pushSw(notifieeId, 'notification', packed);
 		if (type === 'follow') sendEmailNotification.follow(notifieeId, await Users.findOneByOrFail({ id: data.notifierId! }));
 		if (type === 'receiveFollowRequest') sendEmailNotification.receiveFollowRequest(notifieeId, await Users.findOneByOrFail({ id: data.notifierId! }));
 	}, 2000);
diff --git a/packages/backend/src/services/messages/create.ts b/packages/backend/src/services/messages/create.ts
index e5cd5a30d2..e6b3204922 100644
--- a/packages/backend/src/services/messages/create.ts
+++ b/packages/backend/src/services/messages/create.ts
@@ -5,7 +5,7 @@ import { MessagingMessages, UserGroupJoinings, Mutings, Users } from '@/models/i
 import { genId } from '@/misc/gen-id.js';
 import { MessagingMessage } from '@/models/entities/messaging-message.js';
 import { publishMessagingStream, publishMessagingIndexStream, publishMainStream, publishGroupMessagingStream } from '@/services/stream.js';
-import pushNotification from '../push-notification.js';
+import { pushNotification } from '@/services/push-notification.js';
 import { Not } from 'typeorm';
 import { Note } from '@/models/entities/note.js';
 import renderNote from '@/remote/activitypub/renderer/note.js';
diff --git a/packages/backend/src/services/push-notification.ts b/packages/backend/src/services/push-notification.ts
index 41122c92e8..5c3bafbb34 100644
--- a/packages/backend/src/services/push-notification.ts
+++ b/packages/backend/src/services/push-notification.ts
@@ -5,8 +5,15 @@ import { fetchMeta } from '@/misc/fetch-meta.js';
 import { Packed } from '@/misc/schema.js';
 import { getNoteSummary } from '@/misc/get-note-summary.js';
 
-type notificationType = 'notification' | 'unreadMessagingMessage';
-type notificationBody = Packed<'Notification'> | Packed<'MessagingMessage'>;
+// Defined also packages/sw/types.ts#L14-L21
+type pushNotificationsTypes = {
+	'notification': Packed<'Notification'>;
+	'unreadMessagingMessage': Packed<'MessagingMessage'>;
+	'readNotifications': { notificationIds: string[] };
+	'readAllNotifications': undefined;
+	'readAllMessagingMessages': undefined;
+	'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string };
+};
 
 // プッシュメッセージサーバーには文字数制限があるため、内容を削減します
 function truncateNotification(notification: Packed<'Notification'>): any {
@@ -17,12 +24,11 @@ function truncateNotification(notification: Packed<'Notification'>): any {
 				...notification.note,
 				// textをgetNoteSummaryしたものに置き換える
 				text: getNoteSummary(notification.type === 'renote' ? notification.note.renote as Packed<'Note'> : notification.note),
-				...{
-					cw: undefined,
-					reply: undefined,
-					renote: undefined,
-					user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
-				}
+
+				cw: undefined,
+				reply: undefined,
+				renote: undefined,
+				user: undefined as any, // 通知を受け取ったユーザーである場合が多いのでこれも捨てる
 			}
 		};
 	}
@@ -30,7 +36,7 @@ function truncateNotification(notification: Packed<'Notification'>): any {
 	return notification;
 }
 
-export default async function(userId: string, type: notificationType, body: notificationBody) {
+export async function pushNotification<T extends keyof pushNotificationsTypes>(userId: string, type: T, body: pushNotificationsTypes[T]) {
 	const meta = await fetchMeta();
 
 	if (!meta.enableServiceWorker || meta.swPublicKey == null || meta.swPrivateKey == null) return;
diff --git a/packages/backend/yarn.lock b/packages/backend/yarn.lock
index 8fbfa6459b..fd91be84af 100644
--- a/packages/backend/yarn.lock
+++ b/packages/backend/yarn.lock
@@ -1084,7 +1084,7 @@ ansi-styles@^3.2.1:
   dependencies:
     color-convert "^1.9.0"
 
-ansi-styles@^4.0.0, ansi-styles@^4.1.0:
+ansi-styles@^4.0.0:
   version "4.2.1"
   resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.2.1.tgz#90ae75c424d008d2624c5bf29ead3177ebfcf359"
   integrity sha512-9VGjrMsG1vePxcSweQsN20KY/c4zN0h9fLjqAbwbPfahM3t+NL+M9HC8xeXG2I8pX5NoamTGNuomEUFI7fcUjA==
@@ -1092,6 +1092,13 @@ ansi-styles@^4.0.0, ansi-styles@^4.1.0:
     "@types/color-name" "^1.1.1"
     color-convert "^2.0.1"
 
+ansi-styles@^4.1.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
 any-promise@^1.0.0:
   version "1.3.0"
   resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
@@ -1237,11 +1244,6 @@ assert-plus@1.0.0, assert-plus@^1.0.0:
   resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525"
   integrity sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=
 
-async@0.9.x:
-  version "0.9.2"
-  resolved "https://registry.yarnpkg.com/async/-/async-0.9.2.tgz#aea74d5e61c1f899613bf64bda66d4c78f2fd17d"
-  integrity sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0=
-
 async@>=0.2.9:
   version "3.2.0"
   resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
@@ -1306,9 +1308,9 @@ babel-walk@3.0.0-canary-5:
     "@babel/types" "^7.9.6"
 
 balanced-match@^1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.0.tgz#89b4d199ab2bee49de164ea02b89ce462d71b767"
-  integrity sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
 
 base32.js@0.0.1:
   version "0.0.1"
@@ -1402,6 +1404,13 @@ brace-expansion@^1.1.7:
     balanced-match "^1.0.0"
     concat-map "0.0.1"
 
+brace-expansion@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae"
+  integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==
+  dependencies:
+    balanced-match "^1.0.0"
+
 braces@^3.0.1, braces@~3.0.2:
   version "3.0.2"
   resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
@@ -1677,7 +1686,7 @@ chalk@^4.0.0, chalk@^4.1.0:
     ansi-styles "^4.1.0"
     supports-color "^7.1.0"
 
-chalk@^4.1.2:
+chalk@^4.0.2, chalk@^4.1.2:
   version "4.1.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
   integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
@@ -2470,11 +2479,11 @@ ee-first@1.1.1:
   integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=
 
 ejs@^3.1.6:
-  version "3.1.6"
-  resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.6.tgz#5bfd0a0689743bb5268b3550cceeebbc1702822a"
-  integrity sha512-9lt9Zse4hPucPkoP7FHDF0LQAlGyF9JVpnClFLFH3aSSbxmyoqINRpp/9wePWJTUl4KOQwRL72Iw3InHPDkoGw==
+  version "3.1.7"
+  resolved "https://registry.yarnpkg.com/ejs/-/ejs-3.1.7.tgz#c544d9c7f715783dd92f0bddcf73a59e6962d006"
+  integrity sha512-BIar7R6abbUxDA3bfXrO4DSgwo8I+fB5/1zgujl3HLLjwd6+9iOnrT+t3grn2qbk9vOgBubXOFwX2m9axoFaGw==
   dependencies:
-    jake "^10.6.1"
+    jake "^10.8.5"
 
 emoji-regex@^8.0.0:
   version "8.0.0"
@@ -2955,11 +2964,11 @@ file-type@17.1.1:
     token-types "^5.0.0-alpha.2"
 
 filelist@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.2.tgz#80202f21462d4d1c2e214119b1807c1bc0380e5b"
-  integrity sha512-z7O0IS8Plc39rTCq6i6iHxk43duYOn8uFJiWSewIq0Bww1RNybVHSCjahmcC87ZqAm4OTvFzlzeGu3XAzG1ctQ==
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/filelist/-/filelist-1.0.3.tgz#448607750376484932f67ef1b9ff07386b036c83"
+  integrity sha512-LwjCsruLWQULGYKy7TX0OPtrL9kLpojOFKc5VCTxdFTV7w5zbsgqVKfnkKG7Qgjtq50gKfO56hJv88OfcGb70Q==
   dependencies:
-    minimatch "^3.0.4"
+    minimatch "^5.0.1"
 
 fill-range@^7.0.1:
   version "7.0.1"
@@ -3981,13 +3990,13 @@ isexe@^2.0.0:
   resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
   integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
 
-jake@^10.6.1:
-  version "10.8.2"
-  resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.2.tgz#ebc9de8558160a66d82d0eadc6a2e58fbc500a7b"
-  integrity sha512-eLpKyrfG3mzvGE2Du8VoPbeSkRry093+tyNjdYaBbJS9v17knImYGNXQCUV0gLxQtF82m3E8iRb/wdSQZLoq7A==
+jake@^10.8.5:
+  version "10.8.5"
+  resolved "https://registry.yarnpkg.com/jake/-/jake-10.8.5.tgz#f2183d2c59382cb274226034543b9c03b8164c46"
+  integrity sha512-sVpxYeuAhWt0OTWITwT98oyV0GsXyMlXCF+3L1SuafBVUIr/uILGRB+NqwkzhgXKvoJpDIpQvqkUALgdmQsQxw==
   dependencies:
-    async "0.9.x"
-    chalk "^2.4.2"
+    async "^3.2.3"
+    chalk "^4.0.2"
     filelist "^1.0.1"
     minimatch "^3.0.4"
 
@@ -4708,20 +4717,20 @@ minimatch@4.2.1:
   dependencies:
     brace-expansion "^1.1.7"
 
-minimatch@^3.0.4:
-  version "3.0.4"
-  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
-  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
-  dependencies:
-    brace-expansion "^1.1.7"
-
-minimatch@^3.1.2:
+minimatch@^3.0.4, minimatch@^3.1.2:
   version "3.1.2"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.2.tgz#19cd194bfd3e428f049a70817c038d89ab4be35b"
   integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==
   dependencies:
     brace-expansion "^1.1.7"
 
+minimatch@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-5.0.1.tgz#fb9022f7528125187c92bd9e9b6366be1cf3415b"
+  integrity sha512-nLDxIFRyhDblz3qMuq+SoRZED4+miJ/G+tdDrjkkkRnjAsBexeGpgjLEQ0blJy7rHhR2b93rhQY4SvyWu9v03g==
+  dependencies:
+    brace-expansion "^2.0.1"
+
 minimist@^1.2.0, minimist@^1.2.3, minimist@^1.2.5, minimist@^1.2.6:
   version "1.2.6"
   resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
diff --git a/packages/client/src/components/notification.vue b/packages/client/src/components/notification.vue
index 1a360f9905..3791c576ee 100644
--- a/packages/client/src/components/notification.vue
+++ b/packages/client/src/components/notification.vue
@@ -72,7 +72,7 @@
 </template>
 
 <script lang="ts">
-import { defineComponent, ref, onMounted, onUnmounted } from 'vue';
+import { defineComponent, ref, onMounted, onUnmounted, watch } from 'vue';
 import * as misskey from 'misskey-js';
 import { getNoteSummary } from '@/scripts/get-note-summary';
 import XReactionIcon from './reaction-icon.vue';
@@ -126,6 +126,10 @@ export default defineComponent({
 				const connection = stream.useChannel('main');
 				connection.on('readAllNotifications', () => readObserver.disconnect());
 
+				watch(props.notification.isRead, () => {
+					readObserver.disconnect();
+				});
+
 				onUnmounted(() => {
 					readObserver.disconnect();
 					connection.dispose();
diff --git a/packages/client/src/components/notifications.vue b/packages/client/src/components/notifications.vue
index d522503a14..dc900a670d 100644
--- a/packages/client/src/components/notifications.vue
+++ b/packages/client/src/components/notifications.vue
@@ -64,6 +64,31 @@ const onNotification = (notification) => {
 onMounted(() => {
 	const connection = stream.useChannel('main');
 	connection.on('notification', onNotification);
+	connection.on('readAllNotifications', () => {
+		if (pagingComponent.value) {
+			for (const item of pagingComponent.value.queue) {
+				item.isRead = true;
+			}
+			for (const item of pagingComponent.value.items) {
+				item.isRead = true;
+			}
+		}
+	});
+	connection.on('readNotifications', notificationIds => {
+		if (pagingComponent.value) {
+			for (let i = 0; i < pagingComponent.value.queue.length; i++) {
+				if (notificationIds.includes(pagingComponent.value.queue[i].id)) {
+					pagingComponent.value.queue[i].isRead = true;
+				}
+			}
+			for (let i = 0; i < (pagingComponent.value.items || []).length; i++) {
+				if (notificationIds.includes(pagingComponent.value.items[i].id)) {
+					pagingComponent.value.items[i].isRead = true;
+				}
+			}
+		}
+	});
+
 	onUnmounted(() => {
 		connection.dispose();
 	});
diff --git a/packages/client/src/components/ui/pagination.vue b/packages/client/src/components/ui/pagination.vue
index 13f3215671..ac6f59c332 100644
--- a/packages/client/src/components/ui/pagination.vue
+++ b/packages/client/src/components/ui/pagination.vue
@@ -270,6 +270,7 @@ onDeactivated(() => {
 
 defineExpose({
 	items,
+	queue,
 	backed,
 	reload,
 	fetchMoreAhead,
diff --git a/packages/client/src/init.ts b/packages/client/src/init.ts
index 5809f25689..58fdd25835 100644
--- a/packages/client/src/init.ts
+++ b/packages/client/src/init.ts
@@ -149,7 +149,6 @@ if ($i && $i.token) {
 		try {
 			document.body.innerHTML = '<div>Please wait...</div>';
 			await login(i);
-			location.reload();
 		} catch (e) {
 			// Render the error screen
 			// TODO: ちゃんとしたコンポーネントをレンダリングする(v10とかのトラブルシューティングゲーム付きのやつみたいな)
diff --git a/packages/client/src/pages/api-console.vue b/packages/client/src/pages/api-console.vue
index 142a3bee2e..7f174a6318 100644
--- a/packages/client/src/pages/api-console.vue
+++ b/packages/client/src/pages/api-console.vue
@@ -25,8 +25,8 @@
 </MkSpacer>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { defineExpose, ref } from 'vue';
 import * as JSON5 from 'json5';
 import MkButton from '@/components/ui/button.vue';
 import MkInput from '@/components/form/input.vue';
@@ -34,63 +34,51 @@ import MkTextarea from '@/components/form/textarea.vue';
 import MkSwitch from '@/components/form/switch.vue';
 import * as os from '@/os';
 import * as symbols from '@/symbols';
+import { Endpoints } from 'misskey-js';
 
-export default defineComponent({
-	components: {
-		MkButton, MkInput, MkTextarea, MkSwitch,
-	},
+const body = ref('{}');
+const endpoint = ref('');
+const endpoints = ref<any[]>([]);
+const sending = ref(false);
+const res = ref('');
+const withCredential = ref(true);
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: 'API console',
-				icon: 'fas fa-terminal'
-			},
+os.api('endpoints').then(endpointResponse => {
+	endpoints.value = endpointResponse;
+});
 
-			endpoint: '',
-			body: '{}',
-			res: null,
-			sending: false,
-			endpoints: [],
-			withCredential: true,
+function send() {
+	sending.value = true;
+	const requestBody = JSON5.parse(body.value);
+	os.api(endpoint.value as keyof Endpoints, requestBody, requestBody.i || (withCredential.value ? undefined : null)).then(resp => {
+		sending.value = false;
+		res.value = JSON5.stringify(resp, null, 2);
+	}, err => {
+		sending.value = false;
+		res.value = JSON5.stringify(err, null, 2);
+	});
+}
 
-		};
-	},
-
-	created() {
-		os.api('endpoints').then(endpoints => {
-			this.endpoints = endpoints;
-		});
-	},
-
-	methods: {
-		send() {
-			this.sending = true;
-			const body = JSON5.parse(this.body);
-			os.api(this.endpoint, body, body.i || (this.withCredential ? undefined : null)).then(res => {
-				this.sending = false;
-				this.res = JSON5.stringify(res, null, 2);
-			}, err => {
-				this.sending = false;
-				this.res = JSON5.stringify(err, null, 2);
-			});
-		},
-
-		onEndpointChange() {
-			os.api('endpoint', { endpoint: this.endpoint }, this.withCredential ? undefined : null).then(endpoint => {
-				const body = {};
-				for (const p of endpoint.params) {
-					body[p.name] =
-						p.type === 'String' ? '' :
-						p.type === 'Number' ? 0 :
-						p.type === 'Boolean' ? false :
-						p.type === 'Array' ? [] :
-						p.type === 'Object' ? {} :
-						null;
-				}
-				this.body = JSON5.stringify(body, null, 2);
-			});
+function onEndpointChange() {
+	os.api('endpoint', { endpoint: endpoint.value }, withCredential.value ? undefined : null).then(resp => {
+		const endpointBody = {};
+		for (const p of resp.params) {
+			endpointBody[p.name] =
+				p.type === 'String' ? '' :
+				p.type === 'Number' ? 0 :
+				p.type === 'Boolean' ? false :
+				p.type === 'Array' ? [] :
+				p.type === 'Object' ? {} :
+				null;
 		}
-	}
+		body.value = JSON5.stringify(endpointBody, null, 2);
+	});
+}
+
+defineExpose({
+	[symbols.PAGE_INFO]: {
+		title: 'API console',
+		icon: 'fas fa-terminal'
+	},
 });
 </script>
diff --git a/packages/client/src/pages/scratchpad.vue b/packages/client/src/pages/scratchpad.vue
index f871dc48e8..eb91938db2 100644
--- a/packages/client/src/pages/scratchpad.vue
+++ b/packages/client/src/pages/scratchpad.vue
@@ -6,20 +6,20 @@
 	</div>
 
 	<MkContainer :foldable="true" class="_gap">
-		<template #header>{{ $ts.output }}</template>
+		<template #header>{{ i18n.ts.output }}</template>
 		<div class="bepmlvbi">
 			<div v-for="log in logs" :key="log.id" class="log" :class="{ print: log.print }">{{ log.text }}</div>
 		</div>
 	</MkContainer>
 
 	<div class="_gap">
-		{{ $ts.scratchpadDescription }}
+		{{ i18n.ts.scratchpadDescription }}
 	</div>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { defineExpose, ref, watch } from 'vue';
 import 'prismjs';
 import { highlight, languages } from 'prismjs/components/prism-core';
 import 'prismjs/components/prism-clike';
@@ -27,103 +27,90 @@ import 'prismjs/components/prism-javascript';
 import 'prismjs/themes/prism-okaidia.css';
 import { PrismEditor } from 'vue-prism-editor';
 import 'vue-prism-editor/dist/prismeditor.min.css';
-import { AiScript, parse, utils, values } from '@syuilo/aiscript';
+import { AiScript, parse, utils } from '@syuilo/aiscript';
 import MkContainer from '@/components/ui/container.vue';
 import MkButton from '@/components/ui/button.vue';
 import { createAiScriptEnv } from '@/scripts/aiscript/api';
 import * as os from '@/os';
 import * as symbols from '@/symbols';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		MkContainer,
-		MkButton,
-		PrismEditor,
-	},
+const code = ref('');
+const logs = ref<any[]>([]);
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.scratchpad,
-				icon: 'fas fa-terminal',
-			},
-			code: '',
-			logs: [],
-		}
-	},
+const saved = localStorage.getItem('scratchpad');
+if (saved) {
+	code.value = saved;
+}
 
-	watch: {
-		code() {
-			localStorage.setItem('scratchpad', this.code);
-		}
-	},
+watch(code, () => {
+	localStorage.setItem('scratchpad', code.value);
+});
 
-	created() {
-		const saved = localStorage.getItem('scratchpad');
-		if (saved) {
-			this.code = saved;
-		}
-	},
-
-	methods: {
-		async run() {
-			this.logs = [];
-			const aiscript = new AiScript(createAiScriptEnv({
-				storageKey: 'scratchpad',
-				token: this.$i?.token,
-			}), {
-				in: (q) => {
-					return new Promise(ok => {
-						os.inputText({
-							title: q,
-						}).then(({ canceled, result: a }) => {
-							ok(a);
-						});
-					});
-				},
-				out: (value) => {
-					this.logs.push({
-						id: Math.random(),
-						text: value.type === 'str' ? value.value : utils.valToString(value),
-						print: true
-					});
-				},
-				log: (type, params) => {
-					switch (type) {
-						case 'end': this.logs.push({
-							id: Math.random(),
-							text: utils.valToString(params.val, true),
-							print: false
-						}); break;
-						default: break;
-					}
-				}
+async function run() {
+	logs.value = [];
+	const aiscript = new AiScript(createAiScriptEnv({
+		storageKey: 'scratchpad',
+		token: $i?.token,
+	}), {
+		in: (q) => {
+			return new Promise(ok => {
+				os.inputText({
+					title: q,
+				}).then(({ canceled, result: a }) => {
+					ok(a);
+				});
 			});
-
-			let ast;
-			try {
-				ast = parse(this.code);
-			} catch (e) {
-				os.alert({
-					type: 'error',
-					text: 'Syntax error :('
-				});
-				return;
-			}
-			try {
-				await aiscript.exec(ast);
-			} catch (e) {
-				os.alert({
-					type: 'error',
-					text: e
-				});
-			}
 		},
-
-		highlighter(code) {
-			return highlight(code, languages.js, 'javascript');
+		out: (value) => {
+			logs.value.push({
+				id: Math.random(),
+				text: value.type === 'str' ? value.value : utils.valToString(value),
+				print: true
+			});
 		},
+		log: (type, params) => {
+			switch (type) {
+				case 'end': logs.value.push({
+					id: Math.random(),
+					text: utils.valToString(params.val, true),
+					print: false
+				}); break;
+				default: break;
+			}
+		}
+	});
+
+	let ast;
+	try {
+		ast = parse(code.value);
+	} catch (error) {
+		os.alert({
+			type: 'error',
+			text: 'Syntax error :('
+		});
+		return;
 	}
+	try {
+		await aiscript.exec(ast);
+	} catch (error: any) {
+		os.alert({
+			type: 'error',
+			text: error.message
+		});
+	}
+};
+
+function highlighter(code) {
+	return highlight(code, languages.js, 'javascript');
+}
+
+defineExpose({
+	[symbols.PAGE_INFO]: {
+		title: i18n.ts.scratchpad,
+		icon: 'fas fa-terminal',
+	},
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/import-export.vue b/packages/client/src/pages/settings/import-export.vue
index c153b4d28c..127cbcd4c1 100644
--- a/packages/client/src/pages/settings/import-export.vue
+++ b/packages/client/src/pages/settings/import-export.vue
@@ -37,8 +37,8 @@
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent, onMounted, ref } from 'vue';
+<script lang="ts" setup>
+import { defineExpose, ref } from 'vue';
 import MkButton from '@/components/ui/button.vue';
 import FormSection from '@/components/form/section.vue';
 import FormGroup from '@/components/form/group.vue';
@@ -48,108 +48,80 @@ import { selectFile } from '@/scripts/select-file';
 import * as symbols from '@/symbols';
 import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		FormSection,
-		FormGroup,
-		FormSwitch,
-		MkButton,
-	},
+const excludeMutingUsers = ref(false);
+const excludeInactiveUsers = ref(false);
 
-	emits: ['info'],
+const onExportSuccess = () => {
+	os.alert({
+		type: 'info',
+		text: i18n.ts.exportRequested,
+	});
+};
 
-	setup(props, context) {
-		const INFO = {
-			title: i18n.ts.importAndExport,
-			icon: 'fas fa-boxes',
-			bg: 'var(--bg)',
-		};
+const onImportSuccess = () => {
+	os.alert({
+		type: 'info',
+		text: i18n.ts.importRequested,
+	});
+};
 
-		const excludeMutingUsers = ref(false);
-		const excludeInactiveUsers = ref(false);
+const onError = (ev) => {
+	os.alert({
+		type: 'error',
+		text: ev.message,
+	});
+};
 
-		const onExportSuccess = () => {
-			os.alert({
-				type: 'info',
-				text: i18n.ts.exportRequested,
-			});
-		};
+const exportNotes = () => {
+	os.api('i/export-notes', {}).then(onExportSuccess).catch(onError);
+};
 
-		const onImportSuccess = () => {
-			os.alert({
-				type: 'info',
-				text: i18n.ts.importRequested,
-			});
-		};
+const exportFollowing = () => {
+	os.api('i/export-following', {
+		excludeMuting: excludeMutingUsers.value,
+		excludeInactive: excludeInactiveUsers.value,
+	})
+	.then(onExportSuccess).catch(onError);
+};
 
-		const onError = (e) => {
-			os.alert({
-				type: 'error',
-				text: e.message,
-			});
-		};
+const exportBlocking = () => {
+	os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError);
+};
 
-		const exportNotes = () => {
-			os.api('i/export-notes', {}).then(onExportSuccess).catch(onError);
-		};
+const exportUserLists = () => {
+	os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError);
+};
 
-		const exportFollowing = () => {
-			os.api('i/export-following', {
-				excludeMuting: excludeMutingUsers.value,
-				excludeInactive: excludeInactiveUsers.value,
-			})
-			.then(onExportSuccess).catch(onError);
-		};
+const exportMuting = () => {
+	os.api('i/export-mute', {}).then(onExportSuccess).catch(onError);
+};
 
-		const exportBlocking = () => {
-			os.api('i/export-blocking', {}).then(onExportSuccess).catch(onError);
-		};
+const importFollowing = async (ev) => {
+	const file = await selectFile(ev.currentTarget ?? ev.target);
+	os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
 
-		const exportUserLists = () => {
-			os.api('i/export-user-lists', {}).then(onExportSuccess).catch(onError);
-		};
+const importUserLists = async (ev) => {
+	const file = await selectFile(ev.currentTarget ?? ev.target);
+	os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
 
-		const exportMuting = () => {
-			os.api('i/export-mute', {}).then(onExportSuccess).catch(onError);
-		};
+const importMuting = async (ev) => {
+	const file = await selectFile(ev.currentTarget ?? ev.target);
+	os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
 
-		const importFollowing = async (ev) => {
-			const file = await selectFile(ev.currentTarget ?? ev.target);
-			os.api('i/import-following', { fileId: file.id }).then(onImportSuccess).catch(onError);
-		};
+const importBlocking = async (ev) => {
+	const file = await selectFile(ev.currentTarget ?? ev.target);
+	os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
+};
 
-		const importUserLists = async (ev) => {
-			const file = await selectFile(ev.currentTarget ?? ev.target);
-			os.api('i/import-user-lists', { fileId: file.id }).then(onImportSuccess).catch(onError);
-		};
-
-		const importMuting = async (ev) => {
-			const file = await selectFile(ev.currentTarget ?? ev.target);
-			os.api('i/import-muting', { fileId: file.id }).then(onImportSuccess).catch(onError);
-		};
-
-		const importBlocking = async (ev) => {
-			const file = await selectFile(ev.currentTarget ?? ev.target);
-			os.api('i/import-blocking', { fileId: file.id }).then(onImportSuccess).catch(onError);
-		};
-
-		return {
-			[symbols.PAGE_INFO]: INFO,
-			excludeMutingUsers,
-			excludeInactiveUsers,
-
-			exportNotes,
-			exportFollowing,
-			exportBlocking,
-			exportUserLists,
-			exportMuting,
-
-			importFollowing,
-			importUserLists,
-			importMuting,
-			importBlocking,
-		};
-	},
+defineExpose({
+	[symbols.PAGE_INFO]: {
+		title: i18n.ts.importAndExport,
+		icon: 'fas fa-boxes',
+		bg: 'var(--bg)',
+	}
 });
 </script>
 
diff --git a/packages/client/src/pages/settings/instance-mute.vue b/packages/client/src/pages/settings/instance-mute.vue
index f84a209b60..bcc2ee85ad 100644
--- a/packages/client/src/pages/settings/instance-mute.vue
+++ b/packages/client/src/pages/settings/instance-mute.vue
@@ -1,67 +1,51 @@
 <template>
 <div class="_formRoot">
-	<MkInfo>{{ $ts._instanceMute.title }}</MkInfo>
+	<MkInfo>{{ i18n.ts._instanceMute.title }}</MkInfo>
 	<FormTextarea v-model="instanceMutes" class="_formBlock">
-		<template #label>{{ $ts._instanceMute.heading }}</template>
-		<template #caption>{{ $ts._instanceMute.instanceMuteDescription }}<br>{{ $ts._instanceMute.instanceMuteDescription2 }}</template>
+		<template #label>{{ i18n.ts._instanceMute.heading }}</template>
+		<template #caption>{{ i18n.ts._instanceMute.instanceMuteDescription }}<br>{{ i18n.ts._instanceMute.instanceMuteDescription2 }}</template>
 	</FormTextarea>
-	<MkButton primary :disabled="!changed" class="_formBlock" @click="save()"><i class="fas fa-save"></i> {{ $ts.save }}</MkButton>
+	<MkButton primary :disabled="!changed" class="_formBlock" @click="save()"><i class="fas fa-save"></i> {{ i18n.ts.save }}</MkButton>
 </div>
 </template>
 
-<script>
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { defineExpose, ref, watch } from 'vue';
 import FormTextarea from '@/components/form/textarea.vue';
 import MkInfo from '@/components/ui/info.vue';
 import MkButton from '@/components/ui/button.vue';
 import * as os from '@/os';
 import * as symbols from '@/symbols';
+import { $i } from '@/account';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		MkButton,
-		FormTextarea,
-		MkInfo,
-	},
+const instanceMutes = ref($i!.mutedInstances.join('\n'));
+const changed = ref(false);
 
-	emits: ['info'],
+async function save() {
+	let mutes = instanceMutes.value
+		.trim().split('\n')
+		.map(el => el.trim())
+		.filter(el => el);
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.instanceMute,
-				icon: 'fas fa-volume-mute'
-			},
-			tab: 'soft',
-			instanceMutes: '',
-			changed: false,
-		}
-	},
+	await os.api('i/update', {
+		mutedInstances: mutes,
+	});
 
-	watch: {
-		instanceMutes: {
-			handler() {
-				this.changed = true;
-			},
-			deep: true
-		},
-	},
+	changed.value = false;
 
-	async created() {
-		this.instanceMutes = this.$i.mutedInstances.join('\n');
-	},
+	// Refresh filtered list to signal to the user how they've been saved
+	instanceMutes.value = mutes.join('\n');
+}
 
-	methods: {
-		async save() {
-			let mutes = this.instanceMutes.trim().split('\n').map(el => el.trim()).filter(el => el);
-			await os.api('i/update', {
-				mutedInstances: mutes,
-			});
-			this.changed = false;
+watch(instanceMutes, () => {
+	changed.value = true;
+});
 
-			// Refresh filtered list to signal to the user how they've been saved
-			this.instanceMutes = mutes.join('\n');
-		},
+defineExpose({
+	[symbols.PAGE_INFO]: {
+		title: i18n.ts.instanceMute,
+		icon: 'fas fa-volume-mute'
 	}
-})
+});
 </script>
diff --git a/packages/client/src/pages/settings/integration.vue b/packages/client/src/pages/settings/integration.vue
index ca36c91665..75c6200944 100644
--- a/packages/client/src/pages/settings/integration.vue
+++ b/packages/client/src/pages/settings/integration.vue
@@ -1,133 +1,98 @@
 <template>
 <div class="_formRoot">
-	<FormSection v-if="enableTwitterIntegration">
+	<FormSection v-if="instance.enableTwitterIntegration">
 		<template #label><i class="fab fa-twitter"></i> Twitter</template>
-		<p v-if="integrations.twitter">{{ $ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p>
-		<MkButton v-if="integrations.twitter" danger @click="disconnectTwitter">{{ $ts.disconnectService }}</MkButton>
-		<MkButton v-else primary @click="connectTwitter">{{ $ts.connectService }}</MkButton>
+		<p v-if="integrations.twitter">{{ i18n.ts.connectedTo }}: <a :href="`https://twitter.com/${integrations.twitter.screenName}`" rel="nofollow noopener" target="_blank">@{{ integrations.twitter.screenName }}</a></p>
+		<MkButton v-if="integrations.twitter" danger @click="disconnectTwitter">{{ i18n.ts.disconnectService }}</MkButton>
+		<MkButton v-else primary @click="connectTwitter">{{ i18n.ts.connectService }}</MkButton>
 	</FormSection>
 
-	<FormSection v-if="enableDiscordIntegration">
+	<FormSection v-if="instance.enableDiscordIntegration">
 		<template #label><i class="fab fa-discord"></i> Discord</template>
-		<p v-if="integrations.discord">{{ $ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p>
-		<MkButton v-if="integrations.discord" danger @click="disconnectDiscord">{{ $ts.disconnectService }}</MkButton>
-		<MkButton v-else primary @click="connectDiscord">{{ $ts.connectService }}</MkButton>
+		<p v-if="integrations.discord">{{ i18n.ts.connectedTo }}: <a :href="`https://discord.com/users/${integrations.discord.id}`" rel="nofollow noopener" target="_blank">@{{ integrations.discord.username }}#{{ integrations.discord.discriminator }}</a></p>
+		<MkButton v-if="integrations.discord" danger @click="disconnectDiscord">{{ i18n.ts.disconnectService }}</MkButton>
+		<MkButton v-else primary @click="connectDiscord">{{ i18n.ts.connectService }}</MkButton>
 	</FormSection>
 
-	<FormSection v-if="enableGithubIntegration">
+	<FormSection v-if="instance.enableGithubIntegration">
 		<template #label><i class="fab fa-github"></i> GitHub</template>
-		<p v-if="integrations.github">{{ $ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p>
-		<MkButton v-if="integrations.github" danger @click="disconnectGithub">{{ $ts.disconnectService }}</MkButton>
-		<MkButton v-else primary @click="connectGithub">{{ $ts.connectService }}</MkButton>
+		<p v-if="integrations.github">{{ i18n.ts.connectedTo }}: <a :href="`https://github.com/${integrations.github.login}`" rel="nofollow noopener" target="_blank">@{{ integrations.github.login }}</a></p>
+		<MkButton v-if="integrations.github" danger @click="disconnectGithub">{{ i18n.ts.disconnectService }}</MkButton>
+		<MkButton v-else primary @click="connectGithub">{{ i18n.ts.connectService }}</MkButton>
 	</FormSection>
 </div>
 </template>
 
-<script lang="ts">
-import { defineComponent } from 'vue';
+<script lang="ts" setup>
+import { computed, defineExpose, onMounted, ref, watch } from 'vue';
 import { apiUrl } from '@/config';
 import FormSection from '@/components/form/section.vue';
 import MkButton from '@/components/ui/button.vue';
-import * as os from '@/os';
 import * as symbols from '@/symbols';
+import { $i } from '@/account';
+import { instance } from '@/instance';
+import { i18n } from '@/i18n';
 
-export default defineComponent({
-	components: {
-		FormSection,
-		MkButton
-	},
+const twitterForm = ref<Window | null>(null);
+const discordForm = ref<Window | null>(null);
+const githubForm = ref<Window | null>(null);
 
-	emits: ['info'],
+const integrations = computed(() => $i!.integrations);
 
-	data() {
-		return {
-			[symbols.PAGE_INFO]: {
-				title: this.$ts.integration,
-				icon: 'fas fa-share-alt',
-				bg: 'var(--bg)',
-			},
-			apiUrl,
-			twitterForm: null,
-			discordForm: null,
-			githubForm: null,
-			enableTwitterIntegration: false,
-			enableDiscordIntegration: false,
-			enableGithubIntegration: false,
-		};
-	},
+function openWindow(service: string, type: string) {
+	return window.open(`${apiUrl}/${type}/${service}`,
+		`${service}_${type}_window`,
+		'height=570, width=520'
+	);
+}
 
-	computed: {
-		integrations() {
-			return this.$i.integrations;
-		},
-		
-		meta() {
-			return this.$instance;
-		},
-	},
+function connectTwitter() {
+	twitterForm.value = openWindow('twitter', 'connect');
+}
 
-	created() {
-		this.enableTwitterIntegration = this.meta.enableTwitterIntegration;
-		this.enableDiscordIntegration = this.meta.enableDiscordIntegration;
-		this.enableGithubIntegration = this.meta.enableGithubIntegration;
-	},
+function disconnectTwitter() {
+	openWindow('twitter', 'disconnect');
+}
 
-	mounted() {
-		document.cookie = `igi=${this.$i.token}; path=/;` +
-			` max-age=31536000;` +
-			(document.location.protocol.startsWith('https') ? ' secure' : '');
+function connectDiscord() {
+	discordForm.value = openWindow('discord', 'connect');
+}
 
-		this.$watch('integrations', () => {
-			if (this.integrations.twitter) {
-				if (this.twitterForm) this.twitterForm.close();
-			}
-			if (this.integrations.discord) {
-				if (this.discordForm) this.discordForm.close();
-			}
-			if (this.integrations.github) {
-				if (this.githubForm) this.githubForm.close();
-			}
-		}, {
-			deep: true
-		});
-	},
+function disconnectDiscord() {
+	openWindow('discord', 'disconnect');
+}
 
-	methods: {
-		connectTwitter() {
-			this.twitterForm = window.open(apiUrl + '/connect/twitter',
-				'twitter_connect_window',
-				'height=570, width=520');
-		},
+function connectGithub() {
+	githubForm.value = openWindow('github', 'connect');
+}
 
-		disconnectTwitter() {
-			window.open(apiUrl + '/disconnect/twitter',
-				'twitter_disconnect_window',
-				'height=570, width=520');
-		},
+function disconnectGithub() {
+	openWindow('github', 'disconnect');
+}
 
-		connectDiscord() {
-			this.discordForm = window.open(apiUrl + '/connect/discord',
-				'discord_connect_window',
-				'height=570, width=520');
-		},
+onMounted(() => {
+	document.cookie = `igi=${$i!.token}; path=/;` +
+		` max-age=31536000;` +
+		(document.location.protocol.startsWith('https') ? ' secure' : '');
 
-		disconnectDiscord() {
-			window.open(apiUrl + '/disconnect/discord',
-				'discord_disconnect_window',
-				'height=570, width=520');
-		},
+	watch(integrations, () => {
+		if (integrations.value.twitter) {
+			if (twitterForm.value) twitterForm.value.close();
+		}
+		if (integrations.value.discord) {
+			if (discordForm.value) discordForm.value.close();
+		}
+		if (integrations.value.github) {
+			if (githubForm.value) githubForm.value.close();
+		}
+	});
+});
 
-		connectGithub() {
-			this.githubForm = window.open(apiUrl + '/connect/github',
-				'github_connect_window',
-				'height=570, width=520');
-		},
-
-		disconnectGithub() {
-			window.open(apiUrl + '/disconnect/github',
-				'github_disconnect_window',
-				'height=570, width=520');
-		},
+defineExpose({
+	[symbols.PAGE_INFO]: {
+		title: i18n.ts.integration,
+		icon: 'fas fa-share-alt',
+		bg: 'var(--bg)',
 	}
 });
 </script>
diff --git a/packages/client/src/scripts/get-user-name.ts b/packages/client/src/scripts/get-user-name.ts
new file mode 100644
index 0000000000..d499ea0203
--- /dev/null
+++ b/packages/client/src/scripts/get-user-name.ts
@@ -0,0 +1,3 @@
+export default function(user: { name?: string | null, username: string }): string {
+	return user.name || user.username;
+}
diff --git a/packages/client/src/scripts/initialize-sw.ts b/packages/client/src/scripts/initialize-sw.ts
index d6dbd5dbd4..7bacfbdf00 100644
--- a/packages/client/src/scripts/initialize-sw.ts
+++ b/packages/client/src/scripts/initialize-sw.ts
@@ -4,26 +4,26 @@ import { api } from '@/os';
 import { lang } from '@/config';
 
 export async function initializeSw() {
-	if (instance.swPublickey &&
-		('serviceWorker' in navigator) &&
-		('PushManager' in window) &&
-		$i && $i.token) {
-		navigator.serviceWorker.register(`/sw.js`);
+	if (!('serviceWorker' in navigator)) return;
 
-		navigator.serviceWorker.ready.then(registration => {
-			registration.active?.postMessage({
-				msg: 'initialize',
-				lang,
-			});
+	navigator.serviceWorker.register(`/sw.js`, { scope: '/', type: 'classic' });
+	navigator.serviceWorker.ready.then(registration => {
+		registration.active?.postMessage({
+			msg: 'initialize',
+			lang,
+		});
+
+		if (instance.swPublickey && ('PushManager' in window) && $i && $i.token) {
 			// SEE: https://developer.mozilla.org/en-US/docs/Web/API/PushManager/subscribe#Parameters
 			registration.pushManager.subscribe({
 				userVisibleOnly: true,
 				applicationServerKey: urlBase64ToUint8Array(instance.swPublickey)
-			}).then(subscription => {
+			})
+			.then(subscription => {
 				function encode(buffer: ArrayBuffer | null) {
 					return btoa(String.fromCharCode.apply(null, new Uint8Array(buffer)));
 				}
-
+		
 				// Register
 				api('sw/register', {
 					endpoint: subscription.endpoint,
@@ -37,15 +37,15 @@ export async function initializeSw() {
 				if (err.name === 'NotAllowedError') {
 					return;
 				}
-
+		
 				// 違うapplicationServerKey (または gcm_sender_id)のサブスクリプションが
 				// 既に存在していることが原因でエラーになった可能性があるので、
 				// そのサブスクリプションを解除しておく
 				const subscription = await registration.pushManager.getSubscription();
 				if (subscription) subscription.unsubscribe();
 			});
-		});
-	}
+		}
+	});
 }
 
 /**
diff --git a/packages/client/src/sw/compose-notification.ts b/packages/client/src/sw/compose-notification.ts
deleted file mode 100644
index e271d30949..0000000000
--- a/packages/client/src/sw/compose-notification.ts
+++ /dev/null
@@ -1,107 +0,0 @@
-/**
- * Notification composer of Service Worker
- */
-declare var self: ServiceWorkerGlobalScope;
-
-import * as misskey from 'misskey-js';
-
-function getUserName(user: misskey.entities.User): string {
-	return user.name || user.username;
-}
-
-export default async function(type, data, i18n): Promise<[string, NotificationOptions] | null | undefined> {
-	if (!i18n) {
-		console.log('no i18n');
-		return;
-	}
-
-	switch (type) {
-		case 'driveFileCreated': // TODO (Server Side)
-			return [i18n.t('_notification.fileUploaded'), {
-				body: data.name,
-				icon: data.url
-			}];
-		case 'notification':
-			switch (data.type) {
-				case 'mention':
-					return [i18n.t('_notification.youGotMention', { name: getUserName(data.user) }), {
-						body: data.note.text,
-						icon: data.user.avatarUrl
-					}];
-
-				case 'reply':
-					return [i18n.t('_notification.youGotReply', { name: getUserName(data.user) }), {
-						body: data.note.text,
-						icon: data.user.avatarUrl
-					}];
-
-				case 'renote':
-					return [i18n.t('_notification.youRenoted', { name: getUserName(data.user) }), {
-						body: data.note.text,
-						icon: data.user.avatarUrl
-					}];
-
-				case 'quote':
-					return [i18n.t('_notification.youGotQuote', { name: getUserName(data.user) }), {
-						body: data.note.text,
-						icon: data.user.avatarUrl
-					}];
-
-				case 'reaction':
-					return [`${data.reaction} ${getUserName(data.user)}`, {
-						body: data.note.text,
-						icon: data.user.avatarUrl
-					}];
-
-				case 'pollVote':
-					return [i18n.t('_notification.youGotPoll', { name: getUserName(data.user) }), {
-						body: data.note.text,
-						icon: data.user.avatarUrl
-					}];
-
-				case 'pollEnded':
-					return [i18n.t('_notification.pollEnded'), {
-						body: data.note.text,
-					}];
-
-				case 'follow':
-					return [i18n.t('_notification.youWereFollowed'), {
-						body: getUserName(data.user),
-						icon: data.user.avatarUrl
-					}];
-
-				case 'receiveFollowRequest':
-					return [i18n.t('_notification.youReceivedFollowRequest'), {
-						body: getUserName(data.user),
-						icon: data.user.avatarUrl
-					}];
-
-				case 'followRequestAccepted':
-					return [i18n.t('_notification.yourFollowRequestAccepted'), {
-						body: getUserName(data.user),
-						icon: data.user.avatarUrl
-					}];
-
-				case 'groupInvited':
-					return [i18n.t('_notification.youWereInvitedToGroup'), {
-						body: data.group.name
-					}];
-
-				default:
-					return null;
-			}
-		case 'unreadMessagingMessage':
-			if (data.groupId === null) {
-				return [i18n.t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.user) }), {
-					icon: data.user.avatarUrl,
-					tag: `messaging:user:${data.user.id}`
-				}];
-			}
-			return [i18n.t('_notification.youGotMessagingMessageFromGroup', { name: data.group.name }), {
-				icon: data.user.avatarUrl,
-				tag: `messaging:group:${data.group.id}`
-			}];
-		default:
-			return null;
-	}
-}
diff --git a/packages/client/src/sw/sw.ts b/packages/client/src/sw/sw.ts
deleted file mode 100644
index 68c650c771..0000000000
--- a/packages/client/src/sw/sw.ts
+++ /dev/null
@@ -1,123 +0,0 @@
-/**
- * Service Worker
- */
-declare var self: ServiceWorkerGlobalScope;
-
-import { get, set } from 'idb-keyval';
-import composeNotification from '@/sw/compose-notification';
-import { I18n } from '@/scripts/i18n';
-
-//#region Variables
-const version = _VERSION_;
-const cacheName = `mk-cache-${version}`;
-
-let lang: string;
-let i18n: I18n<any>;
-let pushesPool: any[] = [];
-//#endregion
-
-//#region Startup
-get('lang').then(async prelang => {
-	if (!prelang) return;
-	lang = prelang;
-	return fetchLocale();
-});
-//#endregion
-
-//#region Lifecycle: Install
-self.addEventListener('install', ev => {
-	self.skipWaiting();
-});
-//#endregion
-
-//#region Lifecycle: Activate
-self.addEventListener('activate', ev => {
-	ev.waitUntil(
-		caches.keys()
-			.then(cacheNames => Promise.all(
-				cacheNames
-					.filter((v) => v !== cacheName)
-					.map(name => caches.delete(name))
-			))
-			.then(() => self.clients.claim())
-	);
-});
-//#endregion
-
-//#region When: Fetching
-self.addEventListener('fetch', ev => {
-	// Nothing to do
-});
-//#endregion
-
-//#region When: Caught Notification
-self.addEventListener('push', ev => {
-	// クライアント取得
-	ev.waitUntil(self.clients.matchAll({
-		includeUncontrolled: true
-	}).then(async clients => {
-		// クライアントがあったらストリームに接続しているということなので通知しない
-		if (clients.length != 0) return;
-
-		const { type, body } = ev.data?.json();
-
-		// localeを読み込めておらずi18nがundefinedだった場合はpushesPoolにためておく
-		if (!i18n) return pushesPool.push({ type, body });
-
-		const n = await composeNotification(type, body, i18n);
-		if (n) return self.registration.showNotification(...n);
-	}));
-});
-//#endregion
-
-//#region When: Caught a message from the client
-self.addEventListener('message', ev => {
-	switch(ev.data) {
-		case 'clear':
-			return; // TODO
-		default:
-			break;
-	}
-
-	if (typeof ev.data === 'object') {
-		// E.g. '[object Array]' → 'array'
-		const otype = Object.prototype.toString.call(ev.data).slice(8, -1).toLowerCase();
-
-		if (otype === 'object') {
-			if (ev.data.msg === 'initialize') {
-				lang = ev.data.lang;
-				set('lang', lang);
-				fetchLocale();
-			}
-		}
-	}
-});
-//#endregion
-
-//#region Function: (Re)Load i18n instance
-async function fetchLocale() {
-	//#region localeファイルの読み込み
-	// Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う
-	const localeUrl = `/assets/locales/${lang}.${version}.json`;
-	let localeRes = await caches.match(localeUrl);
-
-	if (!localeRes) {
-		localeRes = await fetch(localeUrl);
-		const clone = localeRes?.clone();
-		if (!clone?.clone().ok) return;
-
-		caches.open(cacheName).then(cache => cache.put(localeUrl, clone));
-	}
-
-	i18n = new I18n(await localeRes.json());
-	//#endregion
-
-	//#region i18nをきちんと読み込んだ後にやりたい処理
-	for (const { type, body } of pushesPool) {
-		const n = await composeNotification(type, body, i18n);
-		if (n) self.registration.showNotification(...n);
-	}
-	pushesPool = [];
-	//#endregion
-}
-//#endregion
diff --git a/packages/client/src/ui/_common_/common.vue b/packages/client/src/ui/_common_/common.vue
index 50d95539d1..62e97a11e1 100644
--- a/packages/client/src/ui/_common_/common.vue
+++ b/packages/client/src/ui/_common_/common.vue
@@ -21,6 +21,7 @@ import { popup, popups, pendingApiRequestsCount } from '@/os';
 import { uploads } from '@/scripts/upload';
 import * as sound from '@/scripts/sound';
 import { $i } from '@/account';
+import { swInject } from './sw-inject';
 import { stream } from '@/stream';
 
 export default defineComponent({
@@ -49,6 +50,11 @@ export default defineComponent({
 		if ($i) {
 			const connection = stream.useChannel('main', null, 'UI');
 			connection.on('notification', onNotification);
+
+			//#region Listen message from SW
+			if ('serviceWorker' in navigator) {
+				swInject();
+			}
 		}
 
 		return {
diff --git a/packages/client/src/ui/_common_/sw-inject.ts b/packages/client/src/ui/_common_/sw-inject.ts
new file mode 100644
index 0000000000..e3e2ddd7e6
--- /dev/null
+++ b/packages/client/src/ui/_common_/sw-inject.ts
@@ -0,0 +1,45 @@
+import { inject } from 'vue';
+import { post } from '@/os';
+import { $i, login } from '@/account';
+import { defaultStore } from '@/store';
+import { getAccountFromId } from '@/scripts/get-account-from-id';
+import { router } from '@/router';
+
+export function swInject() {
+	const navHook = inject('navHook', null);
+	const sideViewHook = inject('sideViewHook', null);
+
+	navigator.serviceWorker.addEventListener('message', ev => {
+		if (_DEV_) {
+			console.log('sw msg', ev.data);
+		}
+
+		const data = ev.data; // as SwMessage
+		if (data.type !== 'order') return;
+
+		if (data.loginId !== $i?.id) {
+			return getAccountFromId(data.loginId).then(account => {
+				if (!account) return;
+				return login(account.token, data.url);
+			});
+		}
+
+		switch (data.order) {
+			case 'post':
+				return post(data.options);
+			case 'push':
+				if (router.currentRoute.value.path === data.url) {
+					return window.scroll({ top: 0, behavior: 'smooth' });
+				}
+				if (navHook) {
+					return navHook(data.url);
+				}
+				if (sideViewHook && defaultStore.state.defaultSideView && data.url !== '/') {
+					return sideViewHook(data.url);
+				}
+				return router.push(data.url);
+			default:
+				return;
+		}
+	});
+}
diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json
index f344ae09c6..a06b0356cb 100644
--- a/packages/client/tsconfig.json
+++ b/packages/client/tsconfig.json
@@ -29,8 +29,7 @@
 		],
 		"lib": [
 			"esnext",
-			"dom",
-			"webworker"
+			"dom"
 		]
 	},
 	"compileOnSave": false,
diff --git a/packages/client/webpack.config.js b/packages/client/webpack.config.js
index a50851e17f..adb5fb81b5 100644
--- a/packages/client/webpack.config.js
+++ b/packages/client/webpack.config.js
@@ -37,7 +37,6 @@ const postcss = {
 module.exports = {
 	entry: {
 		app: './src/init.ts',
-		sw: './src/sw/sw.ts'
 	},
 	module: {
 		rules: [{
diff --git a/packages/sw/.eslintrc.js b/packages/sw/.eslintrc.js
new file mode 100644
index 0000000000..9d56daca83
--- /dev/null
+++ b/packages/sw/.eslintrc.js
@@ -0,0 +1,22 @@
+module.exports = {
+	root: true,
+	env: {
+		"node": false
+	},
+	parserOptions: {
+		"parser": "@typescript-eslint/parser",
+		tsconfigRootDir: __dirname,
+		//project: ['./tsconfig.json'],
+	},
+	extends: [
+		//"../shared/.eslintrc.js",
+	],
+	globals: {
+		"require": false,
+		"_DEV_": false,
+		"_LANGS_": false,
+		"_VERSION_": false,
+		"_ENV_": false,
+		"_PERF_PREFIX_": false,
+	}
+}
diff --git a/packages/sw/.npmrc b/packages/sw/.npmrc
new file mode 100644
index 0000000000..6b5f38e890
--- /dev/null
+++ b/packages/sw/.npmrc
@@ -0,0 +1,2 @@
+save-exact = true
+package-lock = false
diff --git a/packages/sw/.yarnrc b/packages/sw/.yarnrc
new file mode 100644
index 0000000000..788570fcd5
--- /dev/null
+++ b/packages/sw/.yarnrc
@@ -0,0 +1 @@
+network-timeout 600000
diff --git a/packages/sw/build.js b/packages/sw/build.js
new file mode 100644
index 0000000000..72d9db9c0f
--- /dev/null
+++ b/packages/sw/build.js
@@ -0,0 +1,37 @@
+const esbuild = require('esbuild');
+const locales = require('../../locales');
+const meta = require('../../package.json');
+const watch = process.argv[2]?.includes('watch');
+
+console.log('Starting SW building...');
+
+esbuild.build({
+	entryPoints: [ `${__dirname}/src/sw.ts` ],
+	bundle: true,
+	format: 'esm',
+	treeShaking: true,
+	minify: process.env.NODE_ENV === 'production',
+	absWorkingDir: __dirname,
+	outbase: `${__dirname}/src`,
+	outdir: `${__dirname}/../../built/_sw_dist_`,
+	loader: {
+		'.ts': 'ts'
+	},
+	tsconfig: `${__dirname}/tsconfig.json`,
+	define: {
+		_VERSION_: JSON.stringify(meta.version),
+		_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
+		_ENV_: JSON.stringify(process.env.NODE_ENV),
+		_DEV_: process.env.NODE_ENV !== 'production',
+		_PERF_PREFIX_: JSON.stringify('Misskey:'),
+	},
+	watch: watch ? {
+		onRebuild(error, result) {
+      if (error) console.error('SW: watch build failed:', error);
+      else console.log('SW: watch build succeeded:', result);
+		},
+	} : false,
+}).then(result => {
+	if (watch) console.log('watching...');
+	else console.log('done,', JSON.stringify(result));
+});
diff --git a/packages/sw/package.json b/packages/sw/package.json
new file mode 100644
index 0000000000..41dfe19b85
--- /dev/null
+++ b/packages/sw/package.json
@@ -0,0 +1,17 @@
+{
+	"private": true,
+	"scripts": {
+		"watch": "node build.js watch",
+		"build": "node build.js",
+		"lint": "eslint --quiet src/**/*.{ts}"
+	},
+	"resolutions": {},
+	"dependencies": {
+		"esbuild": "^0.14.13",
+		"idb-keyval": "^6.0.3",
+		"misskey-js": "0.0.14"
+	},
+	"devDependencies": {
+		"eslint": "^8.2.0"
+	}
+}
diff --git a/packages/sw/src/filters/user.ts b/packages/sw/src/filters/user.ts
new file mode 100644
index 0000000000..09437eb19a
--- /dev/null
+++ b/packages/sw/src/filters/user.ts
@@ -0,0 +1,14 @@
+import * as misskey from 'misskey-js';
+import * as Acct from 'misskey-js/built/acct';
+
+export const acct = (user: misskey.Acct) => {
+	return Acct.toString(user);
+};
+
+export const userName = (user: misskey.entities.User) => {
+	return user.name || user.username;
+};
+
+export const userPage = (user: misskey.Acct, path?, absolute = false) => {
+	return `${absolute ? origin : ''}/@${acct(user)}${(path ? `/${path}` : '')}`;
+};
diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts
new file mode 100644
index 0000000000..6d7ba7d524
--- /dev/null
+++ b/packages/sw/src/scripts/create-notification.ts
@@ -0,0 +1,237 @@
+/*
+ * Notification manager for SW
+ */
+declare var self: ServiceWorkerGlobalScope;
+
+import { swLang } from '@/scripts/lang';
+import { cli } from '@/scripts/operations';
+import { pushNotificationDataMap } from '@/types';
+import getUserName from '@/scripts/get-user-name';
+import { I18n } from '@/scripts/i18n';
+import { getAccountFromId } from '@/scripts/get-account-from-id';
+
+export async function createNotification<K extends keyof pushNotificationDataMap>(data: pushNotificationDataMap[K]) {
+	const n = await composeNotification(data);
+
+	if (n) {
+		return self.registration.showNotification(...n);
+	} else {
+		console.error('Could not compose notification', data);
+		return createEmptyNotification();
+	}
+}
+
+async function composeNotification<K extends keyof pushNotificationDataMap>(data: pushNotificationDataMap[K]): Promise<[string, NotificationOptions] | null> {
+	if (!swLang.i18n) swLang.fetchLocale();
+	const i18n = await swLang.i18n as I18n<any>;
+	const { t } = i18n;
+	switch (data.type) {
+		/*
+		case 'driveFileCreated': // TODO (Server Side)
+			return [t('_notification.fileUploaded'), {
+				body: body.name,
+				icon: body.url,
+				data
+			}];
+		*/
+		case 'notification':
+			switch (data.body.type) {
+				case 'follow':
+					// users/showの型定義をswos.apiへ当てはめるのが困難なのでapiFetch.requestを直接使用
+					const account = await getAccountFromId(data.userId);
+					if (!account) return null;
+					const userDetail = await cli.request('users/show', { userId: data.body.userId }, account.token);
+					return [t('_notification.youWereFollowed'), {
+						body: getUserName(data.body.user),
+						icon: data.body.user.avatarUrl,
+						data,
+						actions: userDetail.isFollowing ? [] : [
+							{
+								action: 'follow',
+								title: t('_notification._actions.followBack')
+							}
+						],
+					}];
+
+				case 'mention':
+					return [t('_notification.youGotMention', { name: getUserName(data.body.user) }), {
+						body: data.body.note.text || '',
+						icon: data.body.user.avatarUrl,
+						data,
+						actions: [
+							{
+								action: 'reply',
+								title: t('_notification._actions.reply')
+							}
+						],
+					}];
+
+				case 'reply':
+					return [t('_notification.youGotReply', { name: getUserName(data.body.user) }), {
+						body: data.body.note.text || '',
+						icon: data.body.user.avatarUrl,
+						data,
+						actions: [
+							{
+								action: 'reply',
+								title: t('_notification._actions.reply')
+							}
+						],
+					}];
+
+				case 'renote':
+					return [t('_notification.youRenoted', { name: getUserName(data.body.user) }), {
+						body: data.body.note.text || '',
+						icon: data.body.user.avatarUrl,
+						data,
+						actions: [
+							{
+								action: 'showUser',
+								title: getUserName(data.body.user)
+							}
+						],
+					}];
+
+				case 'quote':
+					return [t('_notification.youGotQuote', { name: getUserName(data.body.user) }), {
+						body: data.body.note.text || '',
+						icon: data.body.user.avatarUrl,
+						data,
+						actions: [
+							{
+								action: 'reply',
+								title: t('_notification._actions.reply')
+							},
+							...((data.body.note.visibility === 'public' || data.body.note.visibility === 'home') ? [
+							{
+								action: 'renote',
+								title: t('_notification._actions.renote')
+							}
+							] : [])
+						],
+					}];
+
+				case 'reaction':
+					return [`${data.body.reaction} ${getUserName(data.body.user)}`, {
+						body: data.body.note.text || '',
+						icon: data.body.user.avatarUrl,
+						data,
+						actions: [
+							{
+								action: 'showUser',
+								title: getUserName(data.body.user)
+							}
+						],
+					}];
+
+				case 'pollVote':
+					return [t('_notification.youGotPoll', { name: getUserName(data.body.user) }), {
+						body: data.body.note.text || '',
+						icon: data.body.user.avatarUrl,
+						data,
+					}];
+
+				case 'pollEnded':
+					return [t('_notification.pollEnded'), {
+						body: data.body.note.text || '',
+						data,
+					}];
+
+				case 'receiveFollowRequest':
+					return [t('_notification.youReceivedFollowRequest'), {
+						body: getUserName(data.body.user),
+						icon: data.body.user.avatarUrl,
+						data,
+						actions: [
+							{
+								action: 'accept',
+								title: t('accept')
+							},
+							{
+								action: 'reject',
+								title: t('reject')
+							}
+						],
+					}];
+
+				case 'followRequestAccepted':
+					return [t('_notification.yourFollowRequestAccepted'), {
+						body: getUserName(data.body.user),
+						icon: data.body.user.avatarUrl,
+						data,
+					}];
+
+				case 'groupInvited':
+					return [t('_notification.youWereInvitedToGroup', { userName: getUserName(data.body.user) }), {
+						body: data.body.invitation.group.name,
+						data,
+						actions: [
+							{
+								action: 'accept',
+								title: t('accept')
+							},
+							{
+								action: 'reject',
+								title: t('reject')
+							}
+						],
+					}];
+
+				case 'app':
+						return [data.body.header || data.body.body, {
+							body: data.body.header && data.body.body,
+							icon: data.body.icon,
+							data
+						}];
+
+				default:
+					return null;
+			}
+		case 'unreadMessagingMessage':
+			if (data.body.groupId === null) {
+				return [t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.body.user) }), {
+					icon: data.body.user.avatarUrl,
+					tag: `messaging:user:${data.body.userId}`,
+					data,
+					renotify: true,
+				}];
+			}
+			return [t('_notification.youGotMessagingMessageFromGroup', { name: data.body.group.name }), {
+				icon: data.body.user.avatarUrl,
+				tag: `messaging:group:${data.body.groupId}`,
+				data,
+				renotify: true,
+			}];
+		default:
+			return null;
+	}
+}
+
+export async function createEmptyNotification() {
+	return new Promise<void>(async res => {
+		if (!swLang.i18n) swLang.fetchLocale();
+		const i18n = await swLang.i18n as I18n<any>;
+		const { t } = i18n;
+	
+		await self.registration.showNotification(
+			t('_notification.emptyPushNotificationMessage'),
+			{
+				silent: true,
+				tag: 'read_notification',
+			}
+		);
+
+		res();
+
+		setTimeout(async () => {
+			for (const n of
+				[
+					...(await self.registration.getNotifications({ tag: 'user_visible_auto_notification' })),
+					...(await self.registration.getNotifications({ tag: 'read_notification' }))
+				]
+			) {
+				n.close();
+			}
+		}, 1000);
+	});
+}
diff --git a/packages/sw/src/scripts/get-account-from-id.ts b/packages/sw/src/scripts/get-account-from-id.ts
new file mode 100644
index 0000000000..be4cfaeba4
--- /dev/null
+++ b/packages/sw/src/scripts/get-account-from-id.ts
@@ -0,0 +1,7 @@
+import { get } from 'idb-keyval';
+
+export async function getAccountFromId(id: string) {
+	const accounts = await get('accounts') as { token: string; id: string; }[];
+	if (!accounts) console.log('Accounts are not recorded');
+	return accounts.find(e => e.id === id);
+}
diff --git a/packages/sw/src/scripts/get-user-name.ts b/packages/sw/src/scripts/get-user-name.ts
new file mode 100644
index 0000000000..d499ea0203
--- /dev/null
+++ b/packages/sw/src/scripts/get-user-name.ts
@@ -0,0 +1,3 @@
+export default function(user: { name?: string | null, username: string }): string {
+	return user.name || user.username;
+}
diff --git a/packages/sw/src/scripts/i18n.ts b/packages/sw/src/scripts/i18n.ts
new file mode 100644
index 0000000000..3fe88e5514
--- /dev/null
+++ b/packages/sw/src/scripts/i18n.ts
@@ -0,0 +1,29 @@
+export class I18n<T extends Record<string, any>> {
+	public ts: T;
+
+	constructor(locale: T) {
+		this.ts = locale;
+
+		//#region BIND
+		this.t = this.t.bind(this);
+		//#endregion
+	}
+
+	// string にしているのは、ドット区切りでのパス指定を許可するため
+	// なるべくこのメソッド使うよりもlocale直接参照の方がvueのキャッシュ効いてパフォーマンスが良いかも
+	public t(key: string, args?: Record<string, string>): string {
+		try {
+			let str = key.split('.').reduce((o, i) => o[i], this.ts) as unknown as string;
+
+			if (args) {
+				for (const [k, v] of Object.entries(args)) {
+					str = str.replace(`{${k}}`, v);
+				}
+			}
+			return str;
+		} catch (err) {
+			console.warn(`missing localization '${key}'`);
+			return key;
+		}
+	}
+}
diff --git a/packages/sw/src/scripts/lang.ts b/packages/sw/src/scripts/lang.ts
new file mode 100644
index 0000000000..2d05404ef9
--- /dev/null
+++ b/packages/sw/src/scripts/lang.ts
@@ -0,0 +1,47 @@
+/*
+ * Language manager for SW
+ */
+declare var self: ServiceWorkerGlobalScope;
+
+import { get, set } from 'idb-keyval';
+import { I18n } from '@/scripts/i18n';
+
+class SwLang {
+	public cacheName = `mk-cache-${_VERSION_}`;
+
+	public lang: Promise<string> = get('lang').then(async prelang => {
+		if (!prelang) return 'en-US';
+		return prelang;
+	});
+
+	public setLang(newLang: string) {
+		this.lang = Promise.resolve(newLang);
+		set('lang', newLang);
+		return this.fetchLocale();
+	}
+
+	public i18n: Promise<I18n<any>> | null = null;
+
+	public fetchLocale() {
+		return this.i18n = this._fetch();
+	}
+
+	private async _fetch() {
+		// Service Workerは何度も起動しそのたびにlocaleを読み込むので、CacheStorageを使う
+		const localeUrl = `/assets/locales/${await this.lang}.${_VERSION_}.json`;
+		let localeRes = await caches.match(localeUrl);
+
+		// _DEV_がtrueの場合は常に最新化
+		if (!localeRes || _DEV_) {
+			localeRes = await fetch(localeUrl);
+			const clone = localeRes?.clone();
+			if (!clone?.clone().ok) Error('locale fetching error');
+
+			caches.open(this.cacheName).then(cache => cache.put(localeUrl, clone));
+		}
+
+		return new I18n(await localeRes.json());
+	}
+}
+
+export const swLang = new SwLang();
diff --git a/packages/sw/src/scripts/login-id.ts b/packages/sw/src/scripts/login-id.ts
new file mode 100644
index 0000000000..0f9c6be4a9
--- /dev/null
+++ b/packages/sw/src/scripts/login-id.ts
@@ -0,0 +1,11 @@
+export function getUrlWithLoginId(url: string, loginId: string) {
+	const u = new URL(url, origin);
+	u.searchParams.append('loginId', loginId);
+	return u.toString();
+}
+
+export function getUrlWithoutLoginId(url: string) {
+	const u = new URL(url);
+	u.searchParams.delete('loginId');
+	return u.toString();
+}
diff --git a/packages/sw/src/scripts/notification-read.ts b/packages/sw/src/scripts/notification-read.ts
new file mode 100644
index 0000000000..8433f902b4
--- /dev/null
+++ b/packages/sw/src/scripts/notification-read.ts
@@ -0,0 +1,50 @@
+declare var self: ServiceWorkerGlobalScope;
+
+import { get } from 'idb-keyval';
+import { pushNotificationDataMap } from '@/types';
+import { api } from '@/scripts/operations';
+
+type Accounts = {
+	[x: string]: {
+		queue: string[],
+		timeout: number | null
+	}
+};
+
+class SwNotificationReadManager {
+	private accounts: Accounts = {};
+
+	public async construct() {
+		const accounts = await get('accounts');
+		if (!accounts) Error('Accounts are not recorded');
+
+		this.accounts = accounts.reduce((acc, e) => {
+			acc[e.id] = {
+				queue: [],
+				timeout: null
+			};
+			return acc;
+		}, {} as Accounts);
+
+		return this;
+	}
+
+	// プッシュ通知の既読をサーバーに送信
+	public async read<K extends keyof pushNotificationDataMap>(data: pushNotificationDataMap[K]) {
+		if (data.type !== 'notification' || !(data.userId in this.accounts)) return;
+
+		const account = this.accounts[data.userId];
+
+		account.queue.push(data.body.id as string);
+
+		// 最後の呼び出しから200ms待ってまとめて処理する
+		if (account.timeout) clearTimeout(account.timeout);
+		account.timeout = setTimeout(() => {
+			account.timeout = null;
+
+			api('notifications/read', data.userId, { notificationIds: account.queue });
+		}, 200);
+	}
+}
+
+export const swNotificationRead = (new SwNotificationReadManager()).construct();
diff --git a/packages/sw/src/scripts/operations.ts b/packages/sw/src/scripts/operations.ts
new file mode 100644
index 0000000000..02cf0d96cf
--- /dev/null
+++ b/packages/sw/src/scripts/operations.ts
@@ -0,0 +1,70 @@
+/*
+ * Operations
+ * 各種操作
+ */
+declare var self: ServiceWorkerGlobalScope;
+
+import * as Misskey from 'misskey-js';
+import { SwMessage, swMessageOrderType } from '@/types';
+import { acct as getAcct } from '@/filters/user';
+import { getAccountFromId } from '@/scripts/get-account-from-id';
+import { getUrlWithLoginId } from '@/scripts/login-id';
+
+export const cli = new Misskey.api.APIClient({ origin, fetch: (...args) => fetch(...args) });
+
+export async function api<E extends keyof Misskey.Endpoints>(endpoint: E, userId: string, options?: Misskey.Endpoints[E]['req']) {
+	const account = await getAccountFromId(userId);
+	if (!account) return;
+
+	return cli.request(endpoint, options, account.token);
+}
+
+// rendered acctからユーザーを開く
+export function openUser(acct: string, loginId: string) {
+	return openClient('push', `/@${acct}`, loginId, { acct });
+}
+
+// noteIdからノートを開く
+export function openNote(noteId: string, loginId: string) {
+	return openClient('push', `/notes/${noteId}`, loginId, { noteId });
+}
+
+export async function openChat(body: any, loginId: string) {
+	if (body.groupId === null) {
+		return openClient('push', `/my/messaging/${getAcct(body.user)}`, loginId, { body });
+	} else {
+		return openClient('push', `/my/messaging/group/${body.groupId}`, loginId, { body });
+	}
+}
+
+// post-formのオプションから投稿フォームを開く
+export async function openPost(options: any, loginId: string) {
+	// クエリを作成しておく
+	let url = `/share?`;
+	if (options.initialText) url += `text=${options.initialText}&`;
+	if (options.reply) url += `replyId=${options.reply.id}&`;
+	if (options.renote) url += `renoteId=${options.renote.id}&`;
+
+	return openClient('post', url, loginId, { options });
+}
+
+export async function openClient(order: swMessageOrderType, url: string, loginId: string, query: any = {}) {
+	const client = await findClient();
+
+	if (client) {
+		client.postMessage({ type: 'order', ...query, order, loginId, url } as SwMessage);
+		return client;
+	}
+
+	return self.clients.openWindow(getUrlWithLoginId(url, loginId));
+}
+
+export async function findClient() {
+	const clients = await self.clients.matchAll({
+		type: 'window'
+	});
+	for (const c of clients) {
+		if (c.url.indexOf('?zen') < 0) return c;
+	}
+	return null;
+}
diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts
new file mode 100644
index 0000000000..0ba6a6e4af
--- /dev/null
+++ b/packages/sw/src/sw.ts
@@ -0,0 +1,200 @@
+declare var self: ServiceWorkerGlobalScope;
+
+import { createEmptyNotification, createNotification } from '@/scripts/create-notification';
+import { swLang } from '@/scripts/lang';
+import { swNotificationRead } from '@/scripts/notification-read';
+import { pushNotificationDataMap } from '@/types';
+import * as swos from '@/scripts/operations';
+import { acct as getAcct } from '@/filters/user';
+
+self.addEventListener('install', ev => {
+	ev.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener('activate', ev => {
+	ev.waitUntil(
+		caches.keys()
+			.then(cacheNames => Promise.all(
+				cacheNames
+					.filter((v) => v !== swLang.cacheName)
+					.map(name => caches.delete(name))
+			))
+			.then(() => self.clients.claim())
+	);
+});
+
+self.addEventListener('fetch', ev => {
+	ev.respondWith(
+		fetch(ev.request)
+		.catch(() => new Response(`Offline. Service Worker @${_VERSION_}`, { status: 200 }))
+	);
+});
+
+self.addEventListener('push', ev => {
+	// クライアント取得
+	ev.waitUntil(self.clients.matchAll({
+		includeUncontrolled: true,
+		type: 'window'
+	}).then(async <K extends keyof pushNotificationDataMap>(clients: readonly WindowClient[]) => {
+		const data: pushNotificationDataMap[K] = ev.data?.json();
+
+		switch (data.type) {
+			// case 'driveFileCreated':
+			case 'notification':
+			case 'unreadMessagingMessage':
+				// クライアントがあったらストリームに接続しているということなので通知しない
+				if (clients.length != 0) return;
+				return createNotification(data);
+			case 'readAllNotifications':
+				for (const n of await self.registration.getNotifications()) {
+					if (n?.data?.type === 'notification') n.close();
+				}
+				break;
+			case 'readAllMessagingMessages':
+				for (const n of await self.registration.getNotifications()) {
+					if (n?.data?.type === 'unreadMessagingMessage') n.close();
+				}
+				break;
+			case 'readNotifications':
+				for (const n of await self.registration.getNotifications()) {
+					if (data.body?.notificationIds?.includes(n.data.body.id)) {
+						n.close();
+					}
+				}
+				break;
+			case 'readAllMessagingMessagesOfARoom':
+				for (const n of await self.registration.getNotifications()) {
+					if (n.data.type === 'unreadMessagingMessage'
+						&& ('userId' in data.body
+							? data.body.userId === n.data.body.userId
+							: data.body.groupId === n.data.body.groupId)
+						) {
+							n.close();
+						}
+				}
+				break;
+		}
+
+		return createEmptyNotification();
+	}));
+});
+
+self.addEventListener('notificationclick', <K extends keyof pushNotificationDataMap>(ev: ServiceWorkerGlobalScopeEventMap['notificationclick']) => {
+	ev.waitUntil((async () => {
+		if (_DEV_) {
+			console.log('notificationclick', ev.action, ev.notification.data);
+		}
+	
+		const { action, notification } = ev;
+		const data: pushNotificationDataMap[K] = notification.data;
+		const { userId: id } = data;
+		let client: WindowClient | null = null;
+	
+		switch (data.type) {
+			case 'notification':
+				switch (action) {
+					case 'follow':
+						if ('userId' in data.body) await swos.api('following/create', id, { userId: data.body.userId });
+						break;
+					case 'showUser':
+						if ('user' in data.body) client = await swos.openUser(getAcct(data.body.user), id);
+						break;
+					case 'reply':
+						if ('note' in data.body) client = await swos.openPost({ reply: data.body.note }, id);
+						break;
+					case 'renote':
+						if ('note' in data.body) await swos.api('notes/create', id, { renoteId: data.body.note.id });
+						break;
+					case 'accept':
+						switch (data.body.type) {
+							case 'receiveFollowRequest':
+								await swos.api('following/requests/accept', id, { userId: data.body.userId });
+								break;
+							case 'groupInvited':
+								await swos.api('users/groups/invitations/accept', id, { invitationId: data.body.invitation.id });
+								break;
+						}
+						break;
+					case 'reject':
+						switch (data.body.type) {
+							case 'receiveFollowRequest':
+								await swos.api('following/requests/reject', id, { userId: data.body.userId });
+								break;
+							case 'groupInvited':
+								await swos.api('users/groups/invitations/reject', id, { invitationId: data.body.invitation.id });
+								break;
+						}
+						break;
+					case 'showFollowRequests':
+						client = await swos.openClient('push', '/my/follow-requests', id);
+						break;
+					default:
+						switch (data.body.type) {
+							case 'receiveFollowRequest':
+								client = await swos.openClient('push', '/my/follow-requests', id);
+								break;
+							case 'groupInvited':
+								client = await swos.openClient('push', '/my/groups', id);
+								break;
+							case 'reaction':
+								client = await swos.openNote(data.body.note.id, id);
+								break;
+							default:
+								if ('note' in data.body) {
+									client = await swos.openNote(data.body.note.id, id);
+								} else if ('user' in data.body) {
+									client = await swos.openUser(getAcct(data.body.user), id);
+								}
+								break;
+						}
+				}
+				break;
+			case 'unreadMessagingMessage':
+				client = await swos.openChat(data.body, id);
+				break;
+		}
+	
+		if (client) {
+			client.focus();
+		}
+		if (data.type === 'notification') {
+			swNotificationRead.then(that => that.read(data));
+		}
+	
+		notification.close();
+	
+	})());
+});
+
+self.addEventListener('notificationclose', <K extends keyof pushNotificationDataMap>(ev: ServiceWorkerGlobalScopeEventMap['notificationclose']) => {
+	const data: pushNotificationDataMap[K] = ev.notification.data;
+
+	if (data.type === 'notification') {
+		swNotificationRead.then(that => that.read(data));
+	}
+});
+
+self.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['message']) => {
+	ev.waitUntil((async () => {
+		switch (ev.data) {
+			case 'clear':
+				// Cache Storage全削除
+				await caches.keys()
+					.then(cacheNames => Promise.all(
+						cacheNames.map(name => caches.delete(name))
+					));
+				return; // TODO
+		}
+	
+		if (typeof ev.data === 'object') {
+			// E.g. '[object Array]' → 'array'
+			const otype = Object.prototype.toString.call(ev.data).slice(8, -1).toLowerCase();
+	
+			if (otype === 'object') {
+				if (ev.data.msg === 'initialize') {
+					swLang.setLang(ev.data.lang);
+				}
+			}
+		}
+	})());
+});
diff --git a/packages/sw/src/types.ts b/packages/sw/src/types.ts
new file mode 100644
index 0000000000..6aa3726eac
--- /dev/null
+++ b/packages/sw/src/types.ts
@@ -0,0 +1,31 @@
+import * as Misskey from 'misskey-js';
+
+export type swMessageOrderType = 'post' | 'push';
+
+export type SwMessage = {
+	type: 'order';
+	order: swMessageOrderType;
+	loginId: string;
+	url: string;
+	[x: string]: any;
+};
+
+// Defined also @/services/push-notification.ts#L7-L14
+type pushNotificationDataSourceMap = {
+	notification: Misskey.entities.Notification;
+	unreadMessagingMessage: Misskey.entities.MessagingMessage;
+	readNotifications: { notificationIds: string[] };
+	readAllNotifications: undefined;
+	readAllMessagingMessages: undefined;
+	readAllMessagingMessagesOfARoom: { userId: string } | { groupId: string };
+};
+
+export type pushNotificationData<K extends keyof pushNotificationDataSourceMap> = {
+	type: K;
+	body: pushNotificationDataSourceMap[K];
+	userId: string;
+};
+
+export type pushNotificationDataMap = {
+	[K in keyof pushNotificationDataSourceMap]: pushNotificationData<K>;
+};
diff --git a/packages/sw/tsconfig.json b/packages/sw/tsconfig.json
new file mode 100644
index 0000000000..c3a845f12a
--- /dev/null
+++ b/packages/sw/tsconfig.json
@@ -0,0 +1,39 @@
+{
+	"compilerOptions": {
+		"allowJs": true,
+		"noEmitOnError": false,
+		"noImplicitAny": false,
+		"noImplicitReturns": true,
+		"noUnusedParameters": false,
+		"noUnusedLocals": true,
+		"noFallthroughCasesInSwitch": true,
+		"declaration": false,
+		"sourceMap": false,
+		"target": "es2017",
+		"module": "esnext",
+		"moduleResolution": "node",
+		"removeComments": false,
+		"noLib": false,
+		"strict": true,
+		"strictNullChecks": true,
+		"experimentalDecorators": true,
+		"resolveJsonModule": true,
+		"isolatedModules": true,
+		"baseUrl": ".",
+		"paths": {
+			"@/*": ["./src/*"],
+		},
+		"typeRoots": [
+			"node_modules/@types",
+			"@types",
+		],
+		"lib": [
+			"esnext",
+			"webworker"
+		]
+	},
+	"compileOnSave": false,
+	"include": [
+		"./**/*.ts"
+	]
+}
diff --git a/packages/sw/webpack.config.js b/packages/sw/webpack.config.js
new file mode 100644
index 0000000000..a4bcf96ddc
--- /dev/null
+++ b/packages/sw/webpack.config.js
@@ -0,0 +1,71 @@
+/**
+ * webpack configuration
+ */
+
+const fs = require('fs');
+const webpack = require('webpack');
+
+class WebpackOnBuildPlugin {
+	constructor(callback) {
+		this.callback = callback;
+	}
+
+	apply(compiler) {
+		compiler.hooks.done.tap('WebpackOnBuildPlugin', this.callback);
+	}
+}
+
+const isProduction = process.env.NODE_ENV === 'production';
+
+const locales = require('../../locales');
+const meta = require('../../package.json');
+
+module.exports = {
+	target: 'webworker',
+	entry: {
+		['sw-lib']: './src/lib.ts'
+	},
+	module: {
+		rules: [{
+			test: /\.ts$/,
+			exclude: /node_modules/,
+			use: [{
+				loader: 'ts-loader',
+				options: {
+					happyPackMode: true,
+					transpileOnly: true,
+					configFile: __dirname + '/tsconfig.json',
+				}
+			}]
+		}]
+	},
+	plugins: [
+		new webpack.ProgressPlugin({}),
+		new webpack.DefinePlugin({
+			_VERSION_: JSON.stringify(meta.version),
+			_LANGS_: JSON.stringify(Object.entries(locales).map(([k, v]) => [k, v._lang_])),
+			_ENV_: JSON.stringify(process.env.NODE_ENV),
+			_DEV_: process.env.NODE_ENV !== 'production',
+			_PERF_PREFIX_: JSON.stringify('Misskey:'),
+		}),
+	],
+	output: {
+		path: __dirname + '/../../built/_sw_dist_',
+		filename: `[name].js`,
+		publicPath: `/`,
+		pathinfo: false,
+	},
+	resolve: {
+		extensions: [
+			'.js', '.ts', '.json'
+		],
+		alias: {
+			'@': __dirname + '/src/',
+		}
+	},
+	resolveLoader: {
+		modules: ['node_modules']
+	},
+	devtool: false, //'source-map',
+	mode: isProduction ? 'production' : 'development'
+};
diff --git a/packages/sw/yarn.lock b/packages/sw/yarn.lock
new file mode 100644
index 0000000000..e6d683bc42
--- /dev/null
+++ b/packages/sw/yarn.lock
@@ -0,0 +1,710 @@
+# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
+# yarn lockfile v1
+
+
+"@eslint/eslintrc@^1.0.5":
+  version "1.0.5"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.0.5.tgz#33f1b838dbf1f923bfa517e008362b78ddbbf318"
+  integrity sha512-BLxsnmK3KyPunz5wmCCpqy0YelEoxxGmH73Is+Z74oOTMtExcjkr3dDR6quwrjh1YspA8DH9gnX1o069KiS9AQ==
+  dependencies:
+    ajv "^6.12.4"
+    debug "^4.3.2"
+    espree "^9.2.0"
+    globals "^13.9.0"
+    ignore "^4.0.6"
+    import-fresh "^3.2.1"
+    js-yaml "^4.1.0"
+    minimatch "^3.0.4"
+    strip-json-comments "^3.1.1"
+
+"@humanwhocodes/config-array@^0.9.2":
+  version "0.9.3"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.9.3.tgz#f2564c744b387775b436418491f15fce6601f63e"
+  integrity sha512-3xSMlXHh03hCcCmFc0rbKp3Ivt2PFEJnQUJDDMTJQ2wkECZWdq4GePs2ctc5H8zV+cHPaq8k2vU8mrQjA6iHdQ==
+  dependencies:
+    "@humanwhocodes/object-schema" "^1.2.1"
+    debug "^4.1.1"
+    minimatch "^3.0.4"
+
+"@humanwhocodes/object-schema@^1.2.1":
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-1.2.1.tgz#b520529ec21d8e5945a1851dfd1c32e94e39ff45"
+  integrity sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==
+
+acorn-jsx@^5.3.1:
+  version "5.3.2"
+  resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
+  integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
+
+acorn@^8.7.0:
+  version "8.7.0"
+  resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf"
+  integrity sha512-V/LGr1APy+PXIwKebEWrkZPwoeoF+w1jiOBUmuxuiUIaOHtob8Qc9BTrYo7VuI5fR8tqsy+buA2WFooR5olqvQ==
+
+ajv@^6.10.0, ajv@^6.12.4:
+  version "6.12.6"
+  resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.12.6.tgz#baf5a62e802b07d977034586f8c3baf5adf26df4"
+  integrity sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==
+  dependencies:
+    fast-deep-equal "^3.1.1"
+    fast-json-stable-stringify "^2.0.0"
+    json-schema-traverse "^0.4.1"
+    uri-js "^4.2.2"
+
+ansi-regex@^5.0.1:
+  version "5.0.1"
+  resolved "https://registry.yarnpkg.com/ansi-regex/-/ansi-regex-5.0.1.tgz#082cb2c89c9fe8659a311a53bd6a4dc5301db304"
+  integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==
+
+ansi-styles@^4.1.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-4.3.0.tgz#edd803628ae71c04c85ae7a0906edad34b648937"
+  integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==
+  dependencies:
+    color-convert "^2.0.1"
+
+argparse@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38"
+  integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==
+
+autobind-decorator@^2.4.0:
+  version "2.4.0"
+  resolved "https://registry.yarnpkg.com/autobind-decorator/-/autobind-decorator-2.4.0.tgz#ea9e1c98708cf3b5b356f7cf9f10f265ff18239c"
+  integrity sha512-OGYhWUO72V6DafbF8PM8rm3EPbfuyMZcJhtm5/n26IDwO18pohE4eNazLoCGhPiXOCD0gEGmrbU3849QvM8bbw==
+
+balanced-match@^1.0.0:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
+  integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
+
+brace-expansion@^1.1.7:
+  version "1.1.11"
+  resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
+  integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==
+  dependencies:
+    balanced-match "^1.0.0"
+    concat-map "0.0.1"
+
+callsites@^3.0.0:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
+  integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==
+
+chalk@^4.0.0:
+  version "4.1.2"
+  resolved "https://registry.yarnpkg.com/chalk/-/chalk-4.1.2.tgz#aac4e2b7734a740867aeb16bf02aad556a1e7a01"
+  integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==
+  dependencies:
+    ansi-styles "^4.1.0"
+    supports-color "^7.1.0"
+
+color-convert@^2.0.1:
+  version "2.0.1"
+  resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-2.0.1.tgz#72d3a68d598c9bdb3af2ad1e84f21d896abd4de3"
+  integrity sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==
+  dependencies:
+    color-name "~1.1.4"
+
+color-name@~1.1.4:
+  version "1.1.4"
+  resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
+  integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
+
+concat-map@0.0.1:
+  version "0.0.1"
+  resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
+  integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
+
+cross-spawn@^7.0.2:
+  version "7.0.3"
+  resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6"
+  integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==
+  dependencies:
+    path-key "^3.1.0"
+    shebang-command "^2.0.0"
+    which "^2.0.1"
+
+debug@^4.1.1, debug@^4.3.2:
+  version "4.3.3"
+  resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.3.tgz#04266e0b70a98d4462e6e288e38259213332b664"
+  integrity sha512-/zxw5+vh1Tfv+4Qn7a5nsbcJKPaSvCDhojn6FEl9vupwK2VCSDtEiEtqr8DFtzYFOdz63LBkxec7DYuc2jon6Q==
+  dependencies:
+    ms "2.1.2"
+
+deep-is@^0.1.3:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
+  integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
+
+doctrine@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-3.0.0.tgz#addebead72a6574db783639dc87a121773973961"
+  integrity sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==
+  dependencies:
+    esutils "^2.0.2"
+
+esbuild-android-arm64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.17.tgz#7216810cb8d5b8cd03ce70bdc241dcdd90c34755"
+  integrity sha512-y7EJm8ADC9qKbo/dJ2zBXwNdIILJ76tTv7JDGvOkbLT8HJXIsgbpa0NJk7iFhyvP4GpsYvXTbvEQNn0DhyBhLA==
+
+esbuild-darwin-64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.17.tgz#1419e020f41814f8a74ce92b2dcab29a6d47e510"
+  integrity sha512-V2JAP8yyVbW6qR4SVXsEDqRicYM0x5niUuB05IFiE5itPI45k8j2dA2l+DtirR2SGXr+LEqgX347+2VA6eyTiA==
+
+esbuild-darwin-arm64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.17.tgz#95acf1022066d48346a63ffc5e4d36a07b83c9b0"
+  integrity sha512-ENkSKpjF4SImyA2TdHhKiZqtYc1DkMykICe1KSBw0YNF1sentjFI6wu+CRiYMpC7REf/3TQXoems2XPqIqDMlQ==
+
+esbuild-freebsd-64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.17.tgz#a3455199862110854937b05a0eecbed3e1aeec41"
+  integrity sha512-2i0nTNJM8ftNTvtR00vdqkru8XpHwAbkR2MBLoK2IDSzjsLStwCj+mxf6v83eVM9Abe3QA8xP+irqOdBlwDQ2g==
+
+esbuild-freebsd-arm64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.17.tgz#8a70f2a36f5b0da7d2efdd6fd02aa78611007fd0"
+  integrity sha512-QOmRi1n+uly2G7BbMbHb86YiFA5aM7B2T96A6OF1VG57LNwXwy8LPVM0PVjl7f9cV3pE3fy3VtXPJHJo8XggTA==
+
+esbuild-linux-32@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.17.tgz#b7123f6e4780687e017454604d909fbe558862e9"
+  integrity sha512-qG5NDk7FHHUVw01rjHESON0HvigF2X80b645TUlgTKsWRlrbzzHhMCmQguA01O5PiCimKnyoxti8aJIFNHpQnQ==
+
+esbuild-linux-64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.14.17.tgz#47a6b510c2f7faef595a4d6257a629e65385fdc3"
+  integrity sha512-De8OcmNvfNyFfQRLWbfuZqau6NpYBJxNTLP7Ls/PqQcw0HAwfaYThutY8ozHpPbKFPa7wgqabXlIC4NVSWT0/A==
+
+esbuild-linux-arm64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.17.tgz#dfd9022b7215ca660d464fcb20597b88887c7e64"
+  integrity sha512-WDEOD/YRA4J1lxhETKZff3gRxGYqqZEiVwIOqNfvCh2YcwWU2y6UmNGZsxcuKk18wot4dAXCXQyNZgBkVUTCLw==
+
+esbuild-linux-arm@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.17.tgz#e6f6bb9fe52def5260d7d49b790fbec0e7c6d9cb"
+  integrity sha512-ZwsgFUk3gR2pEMJdh5z4Ds18fvGETgElPqmNdx1NtZTCOVlFMAwFB5u/tOR2FrXbMFv+LkGnNxPDh48PYPDz9A==
+
+esbuild-linux-mips64le@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.17.tgz#bceaad33ff18a822b6da0396c6497a231397b6c3"
+  integrity sha512-Lf4X9NB7r6imzp/11TaGs4kWL0DUn1JxI9gAAKotnKh6T8Y/0sLvZSvQS8WvSZcr0V8RRCrRZwiQqjOALUU/9g==
+
+esbuild-linux-ppc64le@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.17.tgz#9562f094d1e5e6c3b61b776b15a9bbd657042654"
+  integrity sha512-aExhxbrK7/Mh9FArdiC9MbvrQz2bGCDI8cBALKJbmhKg0h7LNt6y1E1S9GGBZ/ZXkHDvV9FFVrXXZKFVU5Qpiw==
+
+esbuild-linux-s390x@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.17.tgz#2963cfe62c227bbf1da64e36d4ca0b23db8008fe"
+  integrity sha512-b0T20rNcS7POi5YLw5dFlsiC+riobR5IfppQGn5NWer6QiIkdL1vOx9eC9CUD3z1itpkLboRAZYieZfKfhCA2Q==
+
+esbuild-netbsd-64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.17.tgz#1d156023f9ae6be79b8627ab0cda2d7feb7f3a48"
+  integrity sha512-pFgTaAa2JF18nqNfCND9wOu1jbZ/mbDSaMxUp5fTkLlofyHhXeb5aChgXUkeipty2Pgq0OwOnxjHmiAxMI7N4g==
+
+esbuild-openbsd-64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.17.tgz#3fc44102c9b65375385112f4ce5899ae5e38f349"
+  integrity sha512-K5+plb6gsAfBcFqB0EG4KvLbgBKslVAfEyJggicwt/QoDwQGJAzao4M6zOA4PG7LlXOwWSqv7VmSFbH+b6DyKw==
+
+esbuild-sunos-64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.17.tgz#5bd24e7a7e863ea89d7e4eafd5364a155c9ea507"
+  integrity sha512-o1FINkbHRi9JB1YteOSXZdkDOmVUbmnCxRmTLkHvk8pfCFNpv/5/7ktt95teYKbEiJna2dEt3M4ckJ/+UVnW+w==
+
+esbuild-windows-32@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.17.tgz#8bda31c550fb6b425707114141d2c6ba034dab9b"
+  integrity sha512-Qutilz0I7OADWBtWrC/FD+2O/TNAkhwbZ+wIns7kF87lxIMtmqpBt3KnMk1e4F47aTrZRr0oH55Zhztd7m2PAA==
+
+esbuild-windows-64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.17.tgz#50b42c06908d3ce9fab8f0f9673199de5d0f9cbc"
+  integrity sha512-b21/oRV+PHrav0HkRpKjbM2yNRVe34gAfbdMppbZFea416wa8SrjcmVfSd7n4jgqoTQG0xe+MGgOpwXtjiB3DQ==
+
+esbuild-windows-arm64@0.14.17:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.17.tgz#62d3921a810b64a03fcace76dad4db51d2128b45"
+  integrity sha512-4HN9E1idllewYvptcrrdfTA6DIWgg11kK0Zrv6yjxstJZLJeKxfilGBEaksLGs4Pst2rAYMx3H2vbYq7AWLQNA==
+
+esbuild@^0.14.13:
+  version "0.14.17"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.14.17.tgz#6a634e56447aa0e90b34c42091d472d802d399e5"
+  integrity sha512-JLgyC6Uv31mv9T9Mm2xF1LntUMCNBSzvg2n32d8cTKZMwFr1wmMFY2FkVum98TSoEsDff0cR+Aj49H2sbBcjKQ==
+  optionalDependencies:
+    esbuild-android-arm64 "0.14.17"
+    esbuild-darwin-64 "0.14.17"
+    esbuild-darwin-arm64 "0.14.17"
+    esbuild-freebsd-64 "0.14.17"
+    esbuild-freebsd-arm64 "0.14.17"
+    esbuild-linux-32 "0.14.17"
+    esbuild-linux-64 "0.14.17"
+    esbuild-linux-arm "0.14.17"
+    esbuild-linux-arm64 "0.14.17"
+    esbuild-linux-mips64le "0.14.17"
+    esbuild-linux-ppc64le "0.14.17"
+    esbuild-linux-s390x "0.14.17"
+    esbuild-netbsd-64 "0.14.17"
+    esbuild-openbsd-64 "0.14.17"
+    esbuild-sunos-64 "0.14.17"
+    esbuild-windows-32 "0.14.17"
+    esbuild-windows-64 "0.14.17"
+    esbuild-windows-arm64 "0.14.17"
+
+escape-string-regexp@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz#14ba83a5d373e3d311e5afca29cf5bfad965bf34"
+  integrity sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==
+
+eslint-scope@^7.1.0:
+  version "7.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-scope/-/eslint-scope-7.1.0.tgz#c1f6ea30ac583031f203d65c73e723b01298f153"
+  integrity sha512-aWwkhnS0qAXqNOgKOK0dJ2nvzEbhEvpy8OlJ9kZ0FeZnA6zpjv1/Vei+puGFFX7zkPCkHHXb7IDX3A+7yPrRWg==
+  dependencies:
+    esrecurse "^4.3.0"
+    estraverse "^5.2.0"
+
+eslint-utils@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/eslint-utils/-/eslint-utils-3.0.0.tgz#8aebaface7345bb33559db0a1f13a1d2d48c3672"
+  integrity sha512-uuQC43IGctw68pJA1RgbQS8/NP7rch6Cwd4j3ZBtgo4/8Flj4eGE7ZYSZRN3iq5pVUv6GPdW5Z1RFleo84uLDA==
+  dependencies:
+    eslint-visitor-keys "^2.0.0"
+
+eslint-visitor-keys@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-2.1.0.tgz#f65328259305927392c938ed44eb0a5c9b2bd303"
+  integrity sha512-0rSmRBzXgDzIsD6mGdJgevzgezI534Cer5L/vyMX0kHzT/jiB43jRhd9YUlMGYLQy2zprNmoT8qasCGtY+QaKw==
+
+eslint-visitor-keys@^3.1.0, eslint-visitor-keys@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.2.0.tgz#6fbb166a6798ee5991358bc2daa1ba76cc1254a1"
+  integrity sha512-IOzT0X126zn7ALX0dwFiUQEdsfzrm4+ISsQS8nukaJXwEyYKRSnEIIDULYg1mCtGp7UUXgfGl7BIolXREQK+XQ==
+
+eslint@^8.2.0:
+  version "8.8.0"
+  resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.8.0.tgz#9762b49abad0cb4952539ffdb0a046392e571a2d"
+  integrity sha512-H3KXAzQGBH1plhYS3okDix2ZthuYJlQQEGE5k0IKuEqUSiyu4AmxxlJ2MtTYeJ3xB4jDhcYCwGOg2TXYdnDXlQ==
+  dependencies:
+    "@eslint/eslintrc" "^1.0.5"
+    "@humanwhocodes/config-array" "^0.9.2"
+    ajv "^6.10.0"
+    chalk "^4.0.0"
+    cross-spawn "^7.0.2"
+    debug "^4.3.2"
+    doctrine "^3.0.0"
+    escape-string-regexp "^4.0.0"
+    eslint-scope "^7.1.0"
+    eslint-utils "^3.0.0"
+    eslint-visitor-keys "^3.2.0"
+    espree "^9.3.0"
+    esquery "^1.4.0"
+    esutils "^2.0.2"
+    fast-deep-equal "^3.1.3"
+    file-entry-cache "^6.0.1"
+    functional-red-black-tree "^1.0.1"
+    glob-parent "^6.0.1"
+    globals "^13.6.0"
+    ignore "^5.2.0"
+    import-fresh "^3.0.0"
+    imurmurhash "^0.1.4"
+    is-glob "^4.0.0"
+    js-yaml "^4.1.0"
+    json-stable-stringify-without-jsonify "^1.0.1"
+    levn "^0.4.1"
+    lodash.merge "^4.6.2"
+    minimatch "^3.0.4"
+    natural-compare "^1.4.0"
+    optionator "^0.9.1"
+    regexpp "^3.2.0"
+    strip-ansi "^6.0.1"
+    strip-json-comments "^3.1.0"
+    text-table "^0.2.0"
+    v8-compile-cache "^2.0.3"
+
+espree@^9.2.0, espree@^9.3.0:
+  version "9.3.0"
+  resolved "https://registry.yarnpkg.com/espree/-/espree-9.3.0.tgz#c1240d79183b72aaee6ccfa5a90bc9111df085a8"
+  integrity sha512-d/5nCsb0JcqsSEeQzFZ8DH1RmxPcglRWh24EFTlUEmCKoehXGdpsx0RkHDubqUI8LSAIKMQp4r9SzQ3n+sm4HQ==
+  dependencies:
+    acorn "^8.7.0"
+    acorn-jsx "^5.3.1"
+    eslint-visitor-keys "^3.1.0"
+
+esquery@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/esquery/-/esquery-1.4.0.tgz#2148ffc38b82e8c7057dfed48425b3e61f0f24a5"
+  integrity sha512-cCDispWt5vHHtwMY2YrAQ4ibFkAL8RbH5YGBnZBc90MolvvfkkQcJro/aZiAQUlQ3qgrYS6D6v8Gc5G5CQsc9w==
+  dependencies:
+    estraverse "^5.1.0"
+
+esrecurse@^4.3.0:
+  version "4.3.0"
+  resolved "https://registry.yarnpkg.com/esrecurse/-/esrecurse-4.3.0.tgz#7ad7964d679abb28bee72cec63758b1c5d2c9921"
+  integrity sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==
+  dependencies:
+    estraverse "^5.2.0"
+
+estraverse@^5.1.0, estraverse@^5.2.0:
+  version "5.3.0"
+  resolved "https://registry.yarnpkg.com/estraverse/-/estraverse-5.3.0.tgz#2eea5290702f26ab8fe5370370ff86c965d21123"
+  integrity sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==
+
+esutils@^2.0.2:
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/esutils/-/esutils-2.0.3.tgz#74d2eb4de0b8da1293711910d50775b9b710ef64"
+  integrity sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==
+
+eventemitter3@^4.0.7:
+  version "4.0.7"
+  resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-4.0.7.tgz#2de9b68f6528d5644ef5c59526a1b4a07306169f"
+  integrity sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==
+
+fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3:
+  version "3.1.3"
+  resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz#3a7d56b559d6cbc3eb512325244e619a65c6c525"
+  integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==
+
+fast-json-stable-stringify@^2.0.0:
+  version "2.1.0"
+  resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz#874bf69c6f404c2b5d99c481341399fd55892633"
+  integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==
+
+fast-levenshtein@^2.0.6:
+  version "2.0.6"
+  resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
+  integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
+
+file-entry-cache@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/file-entry-cache/-/file-entry-cache-6.0.1.tgz#211b2dd9659cb0394b073e7323ac3c933d522027"
+  integrity sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==
+  dependencies:
+    flat-cache "^3.0.4"
+
+flat-cache@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/flat-cache/-/flat-cache-3.0.4.tgz#61b0338302b2fe9f957dcc32fc2a87f1c3048b11"
+  integrity sha512-dm9s5Pw7Jc0GvMYbshN6zchCA9RgQlzzEZX3vylR9IqFfS8XciblUXOKfW6SiuJ0e13eDYZoZV5wdrev7P3Nwg==
+  dependencies:
+    flatted "^3.1.0"
+    rimraf "^3.0.2"
+
+flatted@^3.1.0:
+  version "3.2.5"
+  resolved "https://registry.yarnpkg.com/flatted/-/flatted-3.2.5.tgz#76c8584f4fc843db64702a6bd04ab7a8bd666da3"
+  integrity sha512-WIWGi2L3DyTUvUrwRKgGi9TwxQMUEqPOPQBVi71R96jZXJdFskXEmf54BoZaS1kknGODoIGASGEzBUYdyMCBJg==
+
+fs.realpath@^1.0.0:
+  version "1.0.0"
+  resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
+  integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
+
+functional-red-black-tree@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
+  integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
+
+glob-parent@^6.0.1:
+  version "6.0.2"
+  resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-6.0.2.tgz#6d237d99083950c79290f24c7642a3de9a28f9e3"
+  integrity sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==
+  dependencies:
+    is-glob "^4.0.3"
+
+glob@^7.1.3:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/glob/-/glob-7.2.0.tgz#d15535af7732e02e948f4c41628bd910293f6023"
+  integrity sha512-lmLf6gtyrPq8tTjSmrO94wBeQbFR3HbLHbuyD69wuyQkImp2hWqMGB47OX65FBkPffO641IP9jWa1z4ivqG26Q==
+  dependencies:
+    fs.realpath "^1.0.0"
+    inflight "^1.0.4"
+    inherits "2"
+    minimatch "^3.0.4"
+    once "^1.3.0"
+    path-is-absolute "^1.0.0"
+
+globals@^13.6.0, globals@^13.9.0:
+  version "13.12.1"
+  resolved "https://registry.yarnpkg.com/globals/-/globals-13.12.1.tgz#ec206be932e6c77236677127577aa8e50bf1c5cb"
+  integrity sha512-317dFlgY2pdJZ9rspXDks7073GpDmXdfbM3vYYp0HAMKGDh1FfWPleI2ljVNLQX5M5lXcAslTcPTrOrMEFOjyw==
+  dependencies:
+    type-fest "^0.20.2"
+
+has-flag@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-4.0.0.tgz#944771fd9c81c81265c4d6941860da06bb59479b"
+  integrity sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==
+
+idb-keyval@^6.0.3:
+  version "6.1.0"
+  resolved "https://registry.yarnpkg.com/idb-keyval/-/idb-keyval-6.1.0.tgz#e659cff41188e6097d7fadd69926f6adbbe70041"
+  integrity sha512-u/qHZ75rlD3gH+Zah8dAJVJcGW/RfCnfNrFkElC5RpRCnpsCXXhqjVk+6MoVKJ3WhmNbRYdI6IIVP88e+5sxGw==
+  dependencies:
+    safari-14-idb-fix "^3.0.0"
+
+ignore@^4.0.6:
+  version "4.0.6"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
+  integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
+
+ignore@^5.2.0:
+  version "5.2.0"
+  resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
+  integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
+
+import-fresh@^3.0.0, import-fresh@^3.2.1:
+  version "3.3.0"
+  resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
+  integrity sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==
+  dependencies:
+    parent-module "^1.0.0"
+    resolve-from "^4.0.0"
+
+imurmurhash@^0.1.4:
+  version "0.1.4"
+  resolved "https://registry.yarnpkg.com/imurmurhash/-/imurmurhash-0.1.4.tgz#9218b9b2b928a238b13dc4fb6b6d576f231453ea"
+  integrity sha1-khi5srkoojixPcT7a21XbyMUU+o=
+
+inflight@^1.0.4:
+  version "1.0.6"
+  resolved "https://registry.yarnpkg.com/inflight/-/inflight-1.0.6.tgz#49bd6331d7d02d0c09bc910a1075ba8165b56df9"
+  integrity sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=
+  dependencies:
+    once "^1.3.0"
+    wrappy "1"
+
+inherits@2:
+  version "2.0.4"
+  resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c"
+  integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==
+
+is-extglob@^2.1.1:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2"
+  integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
+
+is-glob@^4.0.0, is-glob@^4.0.3:
+  version "4.0.3"
+  resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084"
+  integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==
+  dependencies:
+    is-extglob "^2.1.1"
+
+isexe@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10"
+  integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=
+
+js-yaml@^4.1.0:
+  version "4.1.0"
+  resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.1.0.tgz#c1fb65f8f5017901cdd2c951864ba18458a10602"
+  integrity sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==
+  dependencies:
+    argparse "^2.0.1"
+
+json-schema-traverse@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660"
+  integrity sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==
+
+json-stable-stringify-without-jsonify@^1.0.1:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz#9db7b59496ad3f3cfef30a75142d2d930ad72651"
+  integrity sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=
+
+levn@^0.4.1:
+  version "0.4.1"
+  resolved "https://registry.yarnpkg.com/levn/-/levn-0.4.1.tgz#ae4562c007473b932a6200d403268dd2fffc6ade"
+  integrity sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==
+  dependencies:
+    prelude-ls "^1.2.1"
+    type-check "~0.4.0"
+
+lodash.merge@^4.6.2:
+  version "4.6.2"
+  resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
+  integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+
+minimatch@^3.0.4:
+  version "3.0.4"
+  resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.0.4.tgz#5166e286457f03306064be5497e8dbb0c3d32083"
+  integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==
+  dependencies:
+    brace-expansion "^1.1.7"
+
+misskey-js@0.0.14:
+  version "0.0.14"
+  resolved "https://registry.yarnpkg.com/misskey-js/-/misskey-js-0.0.14.tgz#1a616bdfbe81c6ee6900219eaf425bb5c714dd4d"
+  integrity sha512-bvLx6U3OwQwqHfp/WKwIVwdvNYAAPk0+YblXyxmSG3dwlzCgBRRLcB8o6bNruUDyJgh3t73pLDcOz3myxcUmww==
+  dependencies:
+    autobind-decorator "^2.4.0"
+    eventemitter3 "^4.0.7"
+    reconnecting-websocket "^4.4.0"
+
+ms@2.1.2:
+  version "2.1.2"
+  resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
+  integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
+
+natural-compare@^1.4.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
+  integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
+
+once@^1.3.0:
+  version "1.4.0"
+  resolved "https://registry.yarnpkg.com/once/-/once-1.4.0.tgz#583b1aa775961d4b113ac17d9c50baef9dd76bd1"
+  integrity sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
+  dependencies:
+    wrappy "1"
+
+optionator@^0.9.1:
+  version "0.9.1"
+  resolved "https://registry.yarnpkg.com/optionator/-/optionator-0.9.1.tgz#4f236a6373dae0566a6d43e1326674f50c291499"
+  integrity sha512-74RlY5FCnhq4jRxVUPKDaRwrVNXMqsGsiW6AJw4XK8hmtm10wC0ypZBLw5IIp85NZMr91+qd1RvvENwg7jjRFw==
+  dependencies:
+    deep-is "^0.1.3"
+    fast-levenshtein "^2.0.6"
+    levn "^0.4.1"
+    prelude-ls "^1.2.1"
+    type-check "^0.4.0"
+    word-wrap "^1.2.3"
+
+parent-module@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/parent-module/-/parent-module-1.0.1.tgz#691d2709e78c79fae3a156622452d00762caaaa2"
+  integrity sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==
+  dependencies:
+    callsites "^3.0.0"
+
+path-is-absolute@^1.0.0:
+  version "1.0.1"
+  resolved "https://registry.yarnpkg.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz#174b9268735534ffbc7ace6bf53a5a9e1b5c5f5f"
+  integrity sha1-F0uSaHNVNP+8es5r9TpanhtcX18=
+
+path-key@^3.1.0:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375"
+  integrity sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==
+
+prelude-ls@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/prelude-ls/-/prelude-ls-1.2.1.tgz#debc6489d7a6e6b0e7611888cec880337d316396"
+  integrity sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==
+
+punycode@^2.1.0:
+  version "2.1.1"
+  resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec"
+  integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==
+
+reconnecting-websocket@^4.4.0:
+  version "4.4.0"
+  resolved "https://registry.yarnpkg.com/reconnecting-websocket/-/reconnecting-websocket-4.4.0.tgz#3b0e5b96ef119e78a03135865b8bb0af1b948783"
+  integrity sha512-D2E33ceRPga0NvTDhJmphEgJ7FUYF0v4lr1ki0csq06OdlxKfugGzN0dSkxM/NfqCxYELK4KcaTOUOjTV6Dcng==
+
+regexpp@^3.2.0:
+  version "3.2.0"
+  resolved "https://registry.yarnpkg.com/regexpp/-/regexpp-3.2.0.tgz#0425a2768d8f23bad70ca4b90461fa2f1213e1b2"
+  integrity sha512-pq2bWo9mVD43nbts2wGv17XLiNLya+GklZ8kaDLV2Z08gDCsGpnKn9BFMepvWuHCbyVvY7J5o5+BVvoQbmlJLg==
+
+resolve-from@^4.0.0:
+  version "4.0.0"
+  resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6"
+  integrity sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==
+
+rimraf@^3.0.2:
+  version "3.0.2"
+  resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
+  integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
+  dependencies:
+    glob "^7.1.3"
+
+safari-14-idb-fix@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/safari-14-idb-fix/-/safari-14-idb-fix-3.0.0.tgz#450fc049b996ec7f3fd9ca2f89d32e0761583440"
+  integrity sha512-eBNFLob4PMq8JA1dGyFn6G97q3/WzNtFK4RnzT1fnLq+9RyrGknzYiM/9B12MnKAxuj1IXr7UKYtTNtjyKMBog==
+
+shebang-command@^2.0.0:
+  version "2.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea"
+  integrity sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==
+  dependencies:
+    shebang-regex "^3.0.0"
+
+shebang-regex@^3.0.0:
+  version "3.0.0"
+  resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172"
+  integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==
+
+strip-ansi@^6.0.1:
+  version "6.0.1"
+  resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9"
+  integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==
+  dependencies:
+    ansi-regex "^5.0.1"
+
+strip-json-comments@^3.1.0, strip-json-comments@^3.1.1:
+  version "3.1.1"
+  resolved "https://registry.yarnpkg.com/strip-json-comments/-/strip-json-comments-3.1.1.tgz#31f1281b3832630434831c310c01cccda8cbe006"
+  integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==
+
+supports-color@^7.1.0:
+  version "7.2.0"
+  resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
+  integrity sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==
+  dependencies:
+    has-flag "^4.0.0"
+
+text-table@^0.2.0:
+  version "0.2.0"
+  resolved "https://registry.yarnpkg.com/text-table/-/text-table-0.2.0.tgz#7f5ee823ae805207c00af2df4a84ec3fcfa570b4"
+  integrity sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=
+
+type-check@^0.4.0, type-check@~0.4.0:
+  version "0.4.0"
+  resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1"
+  integrity sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==
+  dependencies:
+    prelude-ls "^1.2.1"
+
+type-fest@^0.20.2:
+  version "0.20.2"
+  resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.20.2.tgz#1bf207f4b28f91583666cb5fbd327887301cd5f4"
+  integrity sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==
+
+uri-js@^4.2.2:
+  version "4.4.1"
+  resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.4.1.tgz#9b1a52595225859e55f669d928f88c6c57f2a77e"
+  integrity sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==
+  dependencies:
+    punycode "^2.1.0"
+
+v8-compile-cache@^2.0.3:
+  version "2.3.0"
+  resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
+  integrity sha512-l8lCEmLcLYZh4nbunNZvQCJc5pv7+RCwa8q/LdUx8u7lsWvPDKmpodJAJNwkAhJC//dFY48KuIEmjtd4RViDrA==
+
+which@^2.0.1:
+  version "2.0.2"
+  resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
+  integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==
+  dependencies:
+    isexe "^2.0.0"
+
+word-wrap@^1.2.3:
+  version "1.2.3"
+  resolved "https://registry.yarnpkg.com/word-wrap/-/word-wrap-1.2.3.tgz#610636f6b1f703891bd34771ccb17fb93b47079c"
+  integrity sha512-Hz/mrNwitNRh/HUAtM/VT/5VH+ygD6DV7mYKZAtHOrbs8U7lvPS6xf7EJKMF0uW1KJCl0H701g3ZGus+muE5vQ==
+
+wrappy@1:
+  version "1.0.2"
+  resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f"
+  integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
diff --git a/scripts/build.js b/scripts/build.js
index 783af78271..608648b953 100644
--- a/scripts/build.js
+++ b/scripts/build.js
@@ -17,6 +17,14 @@ const execa = require('execa');
 		stderr: process.stderr,
 	});
 
+	console.log('building packages/sw ...');
+
+	await execa('npm', ['run', 'build'], {
+		cwd: __dirname + '/../packages/sw',
+		stdout: process.stdout,
+		stderr: process.stderr,
+	});
+
 	console.log('build finishing ...');
 
 	await execa('npm', ['run', 'gulp'], {
diff --git a/scripts/clean-all.js b/scripts/clean-all.js
index 814ff3f257..456b88032b 100644
--- a/scripts/clean-all.js
+++ b/scripts/clean-all.js
@@ -7,6 +7,9 @@ const fs = require('fs');
 	fs.rmSync(__dirname + '/../packages/client/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/client/node_modules', { recursive: true, force: true });
 
+	fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true });
+	fs.rmSync(__dirname + '/../packages/sw/node_modules', { recursive: true, force: true });
+
 	fs.rmSync(__dirname + '/../built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../node_modules', { recursive: true, force: true });
 })();
diff --git a/scripts/clean.js b/scripts/clean.js
index a14f1fb35b..70b9d882b5 100644
--- a/scripts/clean.js
+++ b/scripts/clean.js
@@ -3,5 +3,6 @@ const fs = require('fs');
 (async () => {
 	fs.rmSync(__dirname + '/../packages/backend/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../packages/client/built', { recursive: true, force: true });
+	fs.rmSync(__dirname + '/../packages/sw/built', { recursive: true, force: true });
 	fs.rmSync(__dirname + '/../built', { recursive: true, force: true });
 })();
diff --git a/scripts/dev.js b/scripts/dev.js
index b7dd870c41..c5dbb7b35a 100644
--- a/scripts/dev.js
+++ b/scripts/dev.js
@@ -25,6 +25,12 @@ const execa = require('execa');
 		stderr: process.stderr,
 	});
 
+	execa('npm', ['run', 'watch'], {
+		cwd: __dirname + '/../packages/sw',
+		stdout: process.stdout,
+		stderr: process.stderr,
+	});
+
 	const start = async () => {
 		try {
 			await execa('npm', ['run', 'start'], {
diff --git a/scripts/install-packages.js b/scripts/install-packages.js
index c25063b29a..bc8e016a3c 100644
--- a/scripts/install-packages.js
+++ b/scripts/install-packages.js
@@ -16,4 +16,12 @@ const execa = require('execa');
 		stdout: process.stdout,
 		stderr: process.stderr,
 	});
+
+	console.log('installing dependencies of packages/sw ...');
+
+	await execa('yarn', ['install'], {
+		cwd: __dirname + '/../packages/sw',
+		stdout: process.stdout,
+		stderr: process.stderr,
+	});
 })();
diff --git a/scripts/lint.js b/scripts/lint.js
index 11aa4909b1..72a63f4ba3 100644
--- a/scripts/lint.js
+++ b/scripts/lint.js
@@ -14,4 +14,11 @@ const execa = require('execa');
 		stdout: process.stdout,
 		stderr: process.stderr,
 	});
+
+	console.log('linting packages/sw ...');
+	await execa('npm', ['run', 'lint'], {
+		cwd: __dirname + '/../packages/sw',
+		stdout: process.stdout,
+		stderr: process.stderr,
+	});
 })();