diff --git a/package.json b/package.json index c1fcff712..10ddea2c1 100644 --- a/package.json +++ b/package.json @@ -208,6 +208,7 @@ "seedrandom": "3.0.5", "sharp": "0.29.1", "speakeasy": "2.0.0", + "strict-event-emitter-types": "2.0.0", "stringz": "2.1.0", "style-loader": "3.3.0", "summaly": "2.4.1", diff --git a/src/models/repositories/signin.ts b/src/models/repositories/signin.ts index 9942d2d96..f375f9b5c 100644 --- a/src/models/repositories/signin.ts +++ b/src/models/repositories/signin.ts @@ -4,7 +4,7 @@ import { Signin } from '@/models/entities/signin'; @EntityRepository(Signin) export class SigninRepository extends Repository { public async pack( - src: any, + src: Signin, ) { return src; } diff --git a/src/server/api/common/read-messaging-message.ts b/src/server/api/common/read-messaging-message.ts index 1dce76d2a..33f41b277 100644 --- a/src/server/api/common/read-messaging-message.ts +++ b/src/server/api/common/read-messaging-message.ts @@ -77,7 +77,7 @@ export async function readGroupMessagingMessage( id: In(messageIds) }); - const reads = []; + const reads: MessagingMessage['id'][] = []; for (const message of messages) { if (message.userId === userId) continue; diff --git a/src/server/api/endpoints/antennas/update.ts b/src/server/api/endpoints/antennas/update.ts index ff13e89bc..d69b4feee 100644 --- a/src/server/api/endpoints/antennas/update.ts +++ b/src/server/api/endpoints/antennas/update.ts @@ -137,7 +137,7 @@ export default define(meta, async (ps, user) => { notify: ps.notify, }); - publishInternalEvent('antennaUpdated', Antennas.findOneOrFail(antenna.id)); + publishInternalEvent('antennaUpdated', await Antennas.findOneOrFail(antenna.id)); return await Antennas.pack(antenna.id); }); diff --git a/src/server/api/stream/channels/antenna.ts b/src/server/api/stream/channels/antenna.ts index bf9c53c45..3cbdfebb4 100644 --- a/src/server/api/stream/channels/antenna.ts +++ b/src/server/api/stream/channels/antenna.ts @@ -3,6 +3,7 @@ import Channel from '../channel'; import { Notes } from '@/models/index'; import { isMutedUserRelated } from '@/misc/is-muted-user-related'; import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; +import { StreamMessages } from '../types'; export default class extends Channel { public readonly chName = 'antenna'; @@ -19,11 +20,9 @@ export default class extends Channel { } @autobind - private async onEvent(data: any) { - const { type, body } = data; - - if (type === 'note') { - const note = await Notes.pack(body.id, this.user, { detail: true }); + private async onEvent(data: StreamMessages['antenna']['payload']) { + if (data.type === 'note') { + const note = await Notes.pack(data.body.id, this.user, { detail: true }); // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (isMutedUserRelated(note, this.muting)) return; @@ -34,7 +33,7 @@ export default class extends Channel { this.send('note', note); } else { - this.send(type, body); + this.send(data.type, data.body); } } diff --git a/src/server/api/stream/channels/channel.ts b/src/server/api/stream/channels/channel.ts index 72ddbf93b..bf7942f52 100644 --- a/src/server/api/stream/channels/channel.ts +++ b/src/server/api/stream/channels/channel.ts @@ -4,6 +4,7 @@ import { Notes, Users } from '@/models/index'; import { isMutedUserRelated } from '@/misc/is-muted-user-related'; import { isBlockerUserRelated } from '@/misc/is-blocker-user-related'; import { User } from '@/models/entities/user'; +import { StreamMessages } from '../types'; import { Packed } from '@/misc/schema'; export default class extends Channel { @@ -52,7 +53,7 @@ export default class extends Channel { } @autobind - private onEvent(data: any) { + private onEvent(data: StreamMessages['channel']['payload']) { if (data.type === 'typing') { const id = data.body; const begin = this.typers[id] == null; diff --git a/src/server/api/stream/channels/main.ts b/src/server/api/stream/channels/main.ts index b99cb931d..131ac3047 100644 --- a/src/server/api/stream/channels/main.ts +++ b/src/server/api/stream/channels/main.ts @@ -11,35 +11,33 @@ export default class extends Channel { public async init(params: any) { // Subscribe main stream channel this.subscriber.on(`mainStream:${this.user!.id}`, async data => { - const { type } = data; - let { body } = data; - - switch (type) { + switch (data.type) { case 'notification': { - if (this.muting.has(body.userId)) return; - if (body.note && body.note.isHidden) { - const note = await Notes.pack(body.note.id, this.user, { + if (data.body.userId && this.muting.has(data.body.userId)) return; + + if (data.body.note && data.body.note.isHidden) { + const note = await Notes.pack(data.body.note.id, this.user, { detail: true }); this.connection.cacheNote(note); - body.note = note; + data.body.note = note; } break; } case 'mention': { - if (this.muting.has(body.userId)) return; - if (body.isHidden) { - const note = await Notes.pack(body.id, this.user, { + if (this.muting.has(data.body.userId)) return; + if (data.body.isHidden) { + const note = await Notes.pack(data.body.id, this.user, { detail: true }); this.connection.cacheNote(note); - body = note; + data.body = note; } break; } } - this.send(type, body); + this.send(data.type, data.body); }); } } diff --git a/src/server/api/stream/channels/messaging.ts b/src/server/api/stream/channels/messaging.ts index 015b0a765..c049e880b 100644 --- a/src/server/api/stream/channels/messaging.ts +++ b/src/server/api/stream/channels/messaging.ts @@ -3,6 +3,8 @@ import { readUserMessagingMessage, readGroupMessagingMessage, deliverReadActivit import Channel from '../channel'; import { UserGroupJoinings, Users, MessagingMessages } from '@/models/index'; import { User, ILocalUser, IRemoteUser } from '@/models/entities/user'; +import { UserGroup } from '@/models/entities/user-group'; +import { StreamMessages } from '../types'; export default class extends Channel { public readonly chName = 'messaging'; @@ -12,7 +14,7 @@ export default class extends Channel { private otherpartyId: string | null; private otherparty: User | null; private groupId: string | null; - private subCh: string; + private subCh: `messagingStream:${User['id']}-${User['id']}` | `messagingStream:${UserGroup['id']}`; private typers: Record = {}; private emitTypersIntervalId: ReturnType; @@ -45,7 +47,7 @@ export default class extends Channel { } @autobind - private onEvent(data: any) { + private onEvent(data: StreamMessages['messaging']['payload'] | StreamMessages['groupMessaging']['payload']) { if (data.type === 'typing') { const id = data.body; const begin = this.typers[id] == null; diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts index ccd555e14..da4ea5ec9 100644 --- a/src/server/api/stream/index.ts +++ b/src/server/api/stream/index.ts @@ -14,6 +14,7 @@ import { AccessToken } from '@/models/entities/access-token'; import { UserProfile } from '@/models/entities/user-profile'; import { publishChannelStream, publishGroupMessagingStream, publishMessagingStream } from '@/services/stream'; import { UserGroup } from '@/models/entities/user-group'; +import { StreamEventEmitter, StreamMessages } from './types'; import { Packed } from '@/misc/schema'; /** @@ -28,7 +29,7 @@ export default class Connection { public followingChannels: Set = new Set(); public token?: AccessToken; private wsConnection: websocket.connection; - public subscriber: EventEmitter; + public subscriber: StreamEventEmitter; private channels: Channel[] = []; private subscribingNotes: any = {}; private cachedNotes: Packed<'Note'>[] = []; @@ -46,8 +47,8 @@ export default class Connection { this.wsConnection.on('message', this.onWsConnectionMessage); - this.subscriber.on('broadcast', async ({ type, body }) => { - this.onBroadcastMessage(type, body); + this.subscriber.on('broadcast', data => { + this.onBroadcastMessage(data); }); if (this.user) { @@ -57,43 +58,41 @@ export default class Connection { this.updateFollowingChannels(); this.updateUserProfile(); - this.subscriber.on(`user:${this.user.id}`, ({ type, body }) => { - this.onUserEvent(type, body); - }); + this.subscriber.on(`user:${this.user.id}`, this.onUserEvent); } } @autobind - private onUserEvent(type: string, body: any) { - switch (type) { + private onUserEvent(data: StreamMessages['user']['payload']) { // { type, body }と展開するとそれぞれ型が分離してしまう + switch (data.type) { case 'follow': - this.following.add(body.id); + this.following.add(data.body.id); break; case 'unfollow': - this.following.delete(body.id); + this.following.delete(data.body.id); break; case 'mute': - this.muting.add(body.id); + this.muting.add(data.body.id); break; case 'unmute': - this.muting.delete(body.id); + this.muting.delete(data.body.id); break; // TODO: block events case 'followChannel': - this.followingChannels.add(body.id); + this.followingChannels.add(data.body.id); break; case 'unfollowChannel': - this.followingChannels.delete(body.id); + this.followingChannels.delete(data.body.id); break; case 'updateUserProfile': - this.userProfile = body; + this.userProfile = data.body; break; case 'terminate': @@ -145,8 +144,8 @@ export default class Connection { } @autobind - private onBroadcastMessage(type: string, body: any) { - this.sendMessageToWs(type, body); + private onBroadcastMessage(data: StreamMessages['broadcast']['payload']) { + this.sendMessageToWs(data.type, data.body); } @autobind @@ -249,7 +248,7 @@ export default class Connection { } @autobind - private async onNoteStreamMessage(data: any) { + private async onNoteStreamMessage(data: StreamMessages['note']['payload']) { this.sendMessageToWs('noteUpdated', { id: data.body.id, type: data.type, diff --git a/src/server/api/stream/types.ts b/src/server/api/stream/types.ts new file mode 100644 index 000000000..70eb5c5ce --- /dev/null +++ b/src/server/api/stream/types.ts @@ -0,0 +1,299 @@ +import { EventEmitter } from 'events'; +import Emitter from 'strict-event-emitter-types'; +import { Channel } from '@/models/entities/channel'; +import { User } from '@/models/entities/user'; +import { UserProfile } from '@/models/entities/user-profile'; +import { Note } from '@/models/entities/note'; +import { Antenna } from '@/models/entities/antenna'; +import { DriveFile } from '@/models/entities/drive-file'; +import { DriveFolder } from '@/models/entities/drive-folder'; +import { Emoji } from '@/models/entities/emoji'; +import { UserList } from '@/models/entities/user-list'; +import { MessagingMessage } from '@/models/entities/messaging-message'; +import { UserGroup } from '@/models/entities/user-group'; +import { ReversiGame } from '@/models/entities/games/reversi/game'; +import { AbuseUserReport } from '@/models/entities/abuse-user-report'; +import { Signin } from '@/models/entities/signin'; +import { Page } from '@/models/entities/page'; +import { Packed } from '@/misc/schema'; + +//#region Stream type-body definitions +export interface InternalStreamTypes { + antennaCreated: Antenna; + antennaDeleted: Antenna; + antennaUpdated: Antenna; +} + +export interface BroadcastTypes { + emojiAdded: { + emoji: Packed<'Emoji'>; + }; +} + +export interface UserStreamTypes { + terminate: {}; + followChannel: Channel; + unfollowChannel: Channel; + updateUserProfile: UserProfile; + mute: User; + unmute: User; + follow: Packed<'User'>; + unfollow: Packed<'User'>; + userAdded: Packed<'User'>; +} + +export interface MainStreamTypes { + notification: Packed<'Notification'>; + mention: Packed<'Note'>; + reply: Packed<'Note'>; + renote: Packed<'Note'>; + follow: Packed<'User'>; + followed: Packed<'User'>; + unfollow: Packed<'User'>; + meUpdated: Packed<'User'>; + pageEvent: { + pageId: Page['id']; + event: string; + var: any; + userId: User['id']; + user: Packed<'User'>; + }; + urlUploadFinished: { + marker?: string | null; + file: Packed<'DriveFile'>; + }; + readAllNotifications: undefined; + unreadNotification: Packed<'Notification'>; + unreadMention: Note['id']; + readAllUnreadMentions: undefined; + unreadSpecifiedNote: Note['id']; + readAllUnreadSpecifiedNotes: undefined; + readAllMessagingMessages: undefined; + messagingMessage: Packed<'MessagingMessage'>; + unreadMessagingMessage: Packed<'MessagingMessage'>; + readAllAntennas: undefined; + unreadAntenna: Antenna; + readAllAnnouncements: undefined; + readAllChannels: undefined; + unreadChannel: Note['id']; + myTokenRegenerated: undefined; + reversiNoInvites: undefined; + reversiInvited: Packed<'ReversiMatching'>; + signin: Signin; + registryUpdated: { + scope?: string[]; + key: string; + value: any | null; + }; + driveFileCreated: Packed<'DriveFile'>; + readAntenna: Antenna; +} + +export interface DriveStreamTypes { + fileCreated: Packed<'DriveFile'>; + fileDeleted: DriveFile['id']; + fileUpdated: Packed<'DriveFile'>; + folderCreated: Packed<'DriveFolder'>; + folderDeleted: DriveFolder['id']; + folderUpdated: Packed<'DriveFolder'>; +} + +export interface NoteStreamTypes { + pollVoted: { + choice: number; + userId: User['id']; + }; + deleted: { + deletedAt: Date; + }; + reacted: { + reaction: string; + emoji?: Emoji; + userId: User['id']; + }; + unreacted: { + reaction: string; + userId: User['id']; + }; +} +type NoteStreamEventTypes = { + [key in keyof NoteStreamTypes]: { + id: Note['id']; + body: NoteStreamTypes[key]; + }; +}; + +export interface ChannelStreamTypes { + typing: User['id']; +} + +export interface UserListStreamTypes { + userAdded: Packed<'User'>; + userRemoved: Packed<'User'>; +} + +export interface AntennaStreamTypes { + note: Note; +} + +export interface MessagingStreamTypes { + read: MessagingMessage['id'][]; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface GroupMessagingStreamTypes { + read: { + ids: MessagingMessage['id'][]; + userId: User['id']; + }; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface MessagingIndexStreamTypes { + read: MessagingMessage['id'][]; + message: Packed<'MessagingMessage'>; +} + +export interface ReversiStreamTypes { + matched: Packed<'ReversiGame'>; + invited: Packed<'ReversiMatching'>; +} + +export interface ReversiGameStreamTypes { + started: Packed<'ReversiGame'>; + ended: { + winnerId?: User['id'] | null, + game: Packed<'ReversiGame'>; + }; + updateSettings: { + key: string; + value: FIXME; + }; + initForm: { + userId: User['id']; + form: FIXME; + }; + updateForm: { + userId: User['id']; + id: string; + value: FIXME; + }; + message: { + userId: User['id']; + message: FIXME; + }; + changeAccepts: { + user1: boolean; + user2: boolean; + }; + set: { + at: Date; + color: boolean; + pos: number; + next: boolean; + }; + watching: User['id']; +} + +export interface AdminStreamTypes { + newAbuseUserReport: { + id: AbuseUserReport['id']; + targetUserId: User['id'], + reporterId: User['id'], + comment: string; + }; +} +//#endregion + +// 辞書(interface or type)から{ type, body }ユニオンを定義 +// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type +// VS Codeの展開を防止するためにEvents型を定義 +type Events = { [K in keyof T]: { type: K; body: T[K]; } }; +type EventUnionFromDictionary< + T extends object, + U = Events +> = U[keyof U]; + +// name/messages(spec) pairs dictionary +export type StreamMessages = { + internal: { + name: 'internal'; + payload: EventUnionFromDictionary; + }; + broadcast: { + name: 'broadcast'; + payload: EventUnionFromDictionary; + }; + user: { + name: `user:${User['id']}`; + payload: EventUnionFromDictionary; + }; + main: { + name: `mainStream:${User['id']}`; + payload: EventUnionFromDictionary; + }; + drive: { + name: `driveStream:${User['id']}`; + payload: EventUnionFromDictionary; + }; + note: { + name: `noteStream:${Note['id']}`; + payload: EventUnionFromDictionary; + }; + channel: { + name: `channelStream:${Channel['id']}`; + payload: EventUnionFromDictionary; + }; + userList: { + name: `userListStream:${UserList['id']}`; + payload: EventUnionFromDictionary; + }; + antenna: { + name: `antennaStream:${Antenna['id']}`; + payload: EventUnionFromDictionary; + }; + messaging: { + name: `messagingStream:${User['id']}-${User['id']}`; + payload: EventUnionFromDictionary; + }; + groupMessaging: { + name: `messagingStream:${UserGroup['id']}`; + payload: EventUnionFromDictionary; + }; + messagingIndex: { + name: `messagingIndexStream:${User['id']}`; + payload: EventUnionFromDictionary; + }; + reversi: { + name: `reversiStream:${User['id']}`; + payload: EventUnionFromDictionary; + }; + reversiGame: { + name: `reversiGameStream:${ReversiGame['id']}`; + payload: EventUnionFromDictionary; + }; + admin: { + name: `adminStream:${User['id']}`; + payload: EventUnionFromDictionary; + }; + notes: { + name: 'notesStream'; + payload: Packed<'Note'>; + }; +}; + +// API event definitions +// ストリームごとのEmitterの辞書を用意 +type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter void }> }; +// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection +type UnionToIntersection = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never; +// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする +export type StreamEventEmitter = UnionToIntersection; +// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる + +// provide stream channels union +export type StreamChannels = StreamMessages[keyof StreamMessages]['name']; diff --git a/src/services/stream.ts b/src/services/stream.ts index 4db1a7739..2c308a1b5 100644 --- a/src/services/stream.ts +++ b/src/services/stream.ts @@ -7,9 +7,28 @@ import { UserGroup } from '@/models/entities/user-group'; import config from '@/config/index'; import { Antenna } from '@/models/entities/antenna'; import { Channel } from '@/models/entities/channel'; +import { + StreamChannels, + AdminStreamTypes, + AntennaStreamTypes, + BroadcastTypes, + ChannelStreamTypes, + DriveStreamTypes, + GroupMessagingStreamTypes, + InternalStreamTypes, + MainStreamTypes, + MessagingIndexStreamTypes, + MessagingStreamTypes, + NoteStreamTypes, + ReversiGameStreamTypes, + ReversiStreamTypes, + UserListStreamTypes, + UserStreamTypes +} from '@/server/api/stream/types'; +import { Packed } from '@/misc/schema'; class Publisher { - private publish = (channel: string, type: string | null, value?: any): void => { + private publish = (channel: StreamChannels, type: string | null, value?: any): void => { const message = type == null ? value : value == null ? { type: type, body: null } : { type: type, body: value }; @@ -20,70 +39,70 @@ class Publisher { })); } - public publishInternalEvent = (type: string, value?: any): void => { + public publishInternalEvent = (type: K, value?: InternalStreamTypes[K]): void => { this.publish('internal', type, typeof value === 'undefined' ? null : value); } - public publishUserEvent = (userId: User['id'], type: string, value?: any): void => { + public publishUserEvent = (userId: User['id'], type: K, value?: UserStreamTypes[K]): void => { this.publish(`user:${userId}`, type, typeof value === 'undefined' ? null : value); } - public publishBroadcastStream = (type: string, value?: any): void => { + public publishBroadcastStream = (type: K, value?: BroadcastTypes[K]): void => { this.publish('broadcast', type, typeof value === 'undefined' ? null : value); } - public publishMainStream = (userId: User['id'], type: string, value?: any): void => { + public publishMainStream = (userId: User['id'], type: K, value?: MainStreamTypes[K]): void => { this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); } - public publishDriveStream = (userId: User['id'], type: string, value?: any): void => { + public publishDriveStream = (userId: User['id'], type: K, value?: DriveStreamTypes[K]): void => { this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); } - public publishNoteStream = (noteId: Note['id'], type: string, value: any): void => { + public publishNoteStream = (noteId: Note['id'], type: K, value?: NoteStreamTypes[K]): void => { this.publish(`noteStream:${noteId}`, type, { id: noteId, body: value }); } - public publishChannelStream = (channelId: Channel['id'], type: string, value?: any): void => { + public publishChannelStream = (channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void => { this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value); } - public publishUserListStream = (listId: UserList['id'], type: string, value?: any): void => { + public publishUserListStream = (listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void => { this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); } - public publishAntennaStream = (antennaId: Antenna['id'], type: string, value?: any): void => { + public publishAntennaStream = (antennaId: Antenna['id'], type: K, value?: AntennaStreamTypes[K]): void => { this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); } - public publishMessagingStream = (userId: User['id'], otherpartyId: User['id'], type: string, value?: any): void => { + public publishMessagingStream = (userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void => { this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); } - public publishGroupMessagingStream = (groupId: UserGroup['id'], type: string, value?: any): void => { + public publishGroupMessagingStream = (groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void => { this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value); } - public publishMessagingIndexStream = (userId: User['id'], type: string, value?: any): void => { + public publishMessagingIndexStream = (userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void => { this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); } - public publishReversiStream = (userId: User['id'], type: string, value?: any): void => { + public publishReversiStream = (userId: User['id'], type: K, value?: ReversiStreamTypes[K]): void => { this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value); } - public publishReversiGameStream = (gameId: ReversiGame['id'], type: string, value?: any): void => { + public publishReversiGameStream = (gameId: ReversiGame['id'], type: K, value?: ReversiGameStreamTypes[K]): void => { this.publish(`reversiGameStream:${gameId}`, type, typeof value === 'undefined' ? null : value); } - public publishNotesStream = (note: any): void => { + public publishNotesStream = (note: Packed<'Note'>): void => { this.publish('notesStream', null, note); } - public publishAdminStream = (userId: User['id'], type: string, value?: any): void => { + public publishAdminStream = (userId: User['id'], type: K, value?: AdminStreamTypes[K]): void => { this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); } } diff --git a/yarn.lock b/yarn.lock index cd76b59af..e2140e185 100644 --- a/yarn.lock +++ b/yarn.lock @@ -10288,6 +10288,11 @@ streamsearch@0.1.2: resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-0.1.2.tgz#808b9d0e56fc273d809ba57338e929919a1a9f1a" integrity sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo= +strict-event-emitter-types@2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz#05e15549cb4da1694478a53543e4e2f4abcf277f" + integrity sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA== + strict-uri-encode@^1.0.0: version "1.1.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-1.1.0.tgz#279b225df1d582b1f54e65addd4352e18faa0713"