@kkuffour/solid-moderation-plugin 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.data/.internal/idp/keys/cookie-secret$.json +1 -0
- package/.data/.internal/idp/keys/jwks$.json +1 -0
- package/.data/.internal/setup/current-base-url$.json +1 -0
- package/PLUGIN_DEVELOPER_GUIDE.md +213 -0
- package/README.md +25 -28
- package/components/context.jsonld +11 -0
- package/config/default.json +14 -28
- package/dist/ModerationHandler.d.ts +21 -0
- package/dist/ModerationHandler.d.ts.map +1 -0
- package/dist/ModerationHandler.js +158 -0
- package/dist/ModerationHandler.js.map +1 -0
- package/dist/ModerationHandler.jsonld +126 -0
- package/dist/components/components.jsonld +4 -9
- package/dist/components/context.jsonld +6 -245
- package/dist/index.d.ts +1 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -6
- package/dist/index.js.map +1 -1
- package/dist/providers/SightEngineProvider.jsonld +2 -2
- package/package.json +10 -10
- package/src/ModerationHandler.ts +189 -0
- package/src/index.ts +1 -6
- package/ARCHITECTURE.md +0 -52
- package/CONFIG-GUIDE.md +0 -49
- package/DEVELOPMENT.md +0 -129
- package/ENV-VARIABLES.md +0 -137
- package/INSTALLATION.md +0 -90
- package/MIGRATION.md +0 -81
- package/PRODUCTION.md +0 -186
- package/PUBLISHING.md +0 -104
- package/TESTING.md +0 -93
- package/css-moderation-spec.md +0 -1422
- package/dist/ModerationConfig.d.ts +0 -16
- package/dist/ModerationConfig.d.ts.map +0 -1
- package/dist/ModerationConfig.js +0 -18
- package/dist/ModerationConfig.js.map +0 -1
- package/dist/ModerationConfig.jsonld +0 -66
- package/dist/ModerationMixin.d.ts +0 -13
- package/dist/ModerationMixin.d.ts.map +0 -1
- package/dist/ModerationMixin.js +0 -136
- package/dist/ModerationMixin.js.map +0 -1
- package/dist/ModerationMixin.jsonld +0 -180
- package/dist/ModerationOperationHandler.d.ts +0 -16
- package/dist/ModerationOperationHandler.d.ts.map +0 -1
- package/dist/ModerationOperationHandler.js +0 -45
- package/dist/ModerationOperationHandler.js.map +0 -1
- package/dist/ModerationOperationHandler.jsonld +0 -140
- package/dist/ModerationRecord.d.ts +0 -20
- package/dist/ModerationRecord.d.ts.map +0 -1
- package/dist/ModerationRecord.js +0 -3
- package/dist/ModerationRecord.js.map +0 -1
- package/dist/ModerationRecord.jsonld +0 -59
- package/dist/ModerationResourceStore.d.ts +0 -30
- package/dist/ModerationResourceStore.d.ts.map +0 -1
- package/dist/ModerationResourceStore.js +0 -167
- package/dist/ModerationResourceStore.js.map +0 -1
- package/dist/ModerationResourceStore.jsonld +0 -157
- package/dist/ModerationStore.d.ts +0 -12
- package/dist/ModerationStore.d.ts.map +0 -1
- package/dist/ModerationStore.js +0 -37
- package/dist/ModerationStore.js.map +0 -1
- package/dist/ModerationStore.jsonld +0 -59
- package/dist/util/GuardedStream.d.ts +0 -33
- package/dist/util/GuardedStream.d.ts.map +0 -1
- package/dist/util/GuardedStream.js +0 -89
- package/dist/util/GuardedStream.js.map +0 -1
- package/simple-test.json +0 -7
- package/src/ModerationConfig.ts +0 -29
- package/src/ModerationMixin.ts +0 -153
- package/src/ModerationOperationHandler.ts +0 -64
- package/src/ModerationRecord.ts +0 -19
- package/src/ModerationResourceStore.ts +0 -227
- package/src/ModerationStore.ts +0 -41
- package/src/util/GuardedStream.ts +0 -101
|
@@ -1,227 +0,0 @@
|
|
|
1
|
-
import type { ResourceStore, ChangeMap, Representation, ResourceIdentifier, RepresentationPreferences, Conditions, Patch } from '@solid/community-server';
|
|
2
|
-
import { getLoggerFor, ForbiddenHttpError, guardedStreamFrom } from '@solid/community-server';
|
|
3
|
-
import { writeFileSync, unlinkSync } from 'fs';
|
|
4
|
-
import type { SightEngineProvider } from './providers/SightEngineProvider';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* @module
|
|
8
|
-
* Wraps a ResourceStore to add content moderation on write operations.
|
|
9
|
-
*/
|
|
10
|
-
export class ModerationResourceStore implements ResourceStore {
|
|
11
|
-
private readonly logger = getLoggerFor(this);
|
|
12
|
-
private readonly source: ResourceStore;
|
|
13
|
-
private readonly client: SightEngineProvider;
|
|
14
|
-
private readonly enabled: boolean;
|
|
15
|
-
private readonly imageNudityThreshold: number;
|
|
16
|
-
private readonly textSexualThreshold: number;
|
|
17
|
-
private readonly textToxicThreshold: number;
|
|
18
|
-
private readonly videoNudityThreshold: number;
|
|
19
|
-
|
|
20
|
-
public constructor(
|
|
21
|
-
source: ResourceStore,
|
|
22
|
-
client: SightEngineProvider,
|
|
23
|
-
enabled: boolean,
|
|
24
|
-
imageNudityThreshold: number,
|
|
25
|
-
textSexualThreshold: number,
|
|
26
|
-
textToxicThreshold: number,
|
|
27
|
-
videoNudityThreshold: number,
|
|
28
|
-
) {
|
|
29
|
-
this.source = source;
|
|
30
|
-
this.client = client;
|
|
31
|
-
this.enabled = enabled;
|
|
32
|
-
this.imageNudityThreshold = imageNudityThreshold;
|
|
33
|
-
this.textSexualThreshold = textSexualThreshold;
|
|
34
|
-
this.textToxicThreshold = textToxicThreshold;
|
|
35
|
-
this.videoNudityThreshold = videoNudityThreshold;
|
|
36
|
-
|
|
37
|
-
this.logger.info('ModerationResourceStore initialized');
|
|
38
|
-
this.logger.info(` Enabled: ${enabled}`);
|
|
39
|
-
this.logger.info(` Thresholds: image=${imageNudityThreshold}, text=${textSexualThreshold}/${textToxicThreshold}, video=${videoNudityThreshold}`);
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
public async hasResource(identifier: ResourceIdentifier): Promise<boolean> {
|
|
43
|
-
return this.source.hasResource(identifier);
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
public async getRepresentation(
|
|
47
|
-
identifier: ResourceIdentifier,
|
|
48
|
-
preferences: RepresentationPreferences,
|
|
49
|
-
conditions?: Conditions,
|
|
50
|
-
): Promise<Representation> {
|
|
51
|
-
return this.source.getRepresentation(identifier, preferences, conditions);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
public async setRepresentation(
|
|
55
|
-
identifier: ResourceIdentifier,
|
|
56
|
-
representation: Representation,
|
|
57
|
-
conditions?: Conditions,
|
|
58
|
-
): Promise<ChangeMap> {
|
|
59
|
-
await this.moderateRepresentation(identifier, representation);
|
|
60
|
-
return this.source.setRepresentation(identifier, representation, conditions);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
public async addResource(
|
|
64
|
-
container: ResourceIdentifier,
|
|
65
|
-
representation: Representation,
|
|
66
|
-
conditions?: Conditions,
|
|
67
|
-
): Promise<ChangeMap> {
|
|
68
|
-
await this.moderateRepresentation(container, representation);
|
|
69
|
-
return this.source.addResource(container, representation, conditions);
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
public async deleteResource(
|
|
73
|
-
identifier: ResourceIdentifier,
|
|
74
|
-
conditions?: Conditions,
|
|
75
|
-
): Promise<ChangeMap> {
|
|
76
|
-
return this.source.deleteResource(identifier, conditions);
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
public async modifyResource(
|
|
80
|
-
identifier: ResourceIdentifier,
|
|
81
|
-
patch: Patch,
|
|
82
|
-
conditions?: Conditions,
|
|
83
|
-
): Promise<ChangeMap> {
|
|
84
|
-
return this.source.modifyResource(identifier, patch, conditions);
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
private async moderateRepresentation(
|
|
88
|
-
identifier: ResourceIdentifier,
|
|
89
|
-
representation: Representation,
|
|
90
|
-
): Promise<void> {
|
|
91
|
-
if (!this.enabled || !representation.data) {
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
const contentType = representation.metadata.contentType;
|
|
96
|
-
if (!contentType) {
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const shouldModerate = this.shouldModerateContentType(contentType);
|
|
101
|
-
if (!shouldModerate) {
|
|
102
|
-
return;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
this.logger.info(`Moderating ${contentType} upload to ${identifier.path}`);
|
|
106
|
-
|
|
107
|
-
try {
|
|
108
|
-
const chunks: Buffer[] = [];
|
|
109
|
-
for await (const chunk of representation.data) {
|
|
110
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
111
|
-
}
|
|
112
|
-
const buffer = Buffer.concat(chunks);
|
|
113
|
-
|
|
114
|
-
if (contentType.startsWith('image/')) {
|
|
115
|
-
await this.moderateImage(identifier.path, buffer, contentType);
|
|
116
|
-
} else if (contentType.startsWith('video/')) {
|
|
117
|
-
await this.moderateVideo(identifier.path, buffer, contentType);
|
|
118
|
-
} else if (contentType.startsWith('text/')) {
|
|
119
|
-
await this.moderateText(identifier.path, buffer);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
representation.data = guardedStreamFrom(buffer);
|
|
123
|
-
|
|
124
|
-
} catch (error: unknown) {
|
|
125
|
-
if (error instanceof ForbiddenHttpError) {
|
|
126
|
-
throw error;
|
|
127
|
-
}
|
|
128
|
-
this.logger.error(`Moderation failed for ${identifier.path}: ${(error as Error).message}`);
|
|
129
|
-
this.logger.warn('Allowing content through due to moderation failure (fail-open policy)');
|
|
130
|
-
|
|
131
|
-
// Re-create stream from buffer if we have it
|
|
132
|
-
const chunks: Buffer[] = [];
|
|
133
|
-
try {
|
|
134
|
-
for await (const chunk of representation.data) {
|
|
135
|
-
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
136
|
-
}
|
|
137
|
-
representation.data = guardedStreamFrom(Buffer.concat(chunks));
|
|
138
|
-
} catch {
|
|
139
|
-
// Stream already consumed, can't recover
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
private shouldModerateContentType(contentType: string): boolean {
|
|
145
|
-
return contentType.startsWith('image/') ||
|
|
146
|
-
contentType.startsWith('video/') ||
|
|
147
|
-
contentType.startsWith('text/');
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
private async moderateImage(path: string, buffer: Buffer, contentType: string): Promise<void> {
|
|
151
|
-
const tempFile = `/tmp/moderation_${Date.now()}.${this.getFileExtension(contentType)}`;
|
|
152
|
-
writeFileSync(tempFile, buffer);
|
|
153
|
-
|
|
154
|
-
try {
|
|
155
|
-
const result = await this.client.analyzeImage(tempFile);
|
|
156
|
-
|
|
157
|
-
if (result.nudity?.raw && result.nudity.raw > this.imageNudityThreshold) {
|
|
158
|
-
this.logger.warn(`Image BLOCKED: ${path} - nudity score ${result.nudity.raw.toFixed(2)} > ${this.imageNudityThreshold}`);
|
|
159
|
-
throw new ForbiddenHttpError(`Image contains inappropriate content (nudity: ${result.nudity.raw.toFixed(2)})`);
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
this.logger.info(`Image APPROVED: ${path}`);
|
|
163
|
-
} finally {
|
|
164
|
-
try {
|
|
165
|
-
unlinkSync(tempFile);
|
|
166
|
-
} catch {
|
|
167
|
-
this.logger.warn(`Failed to cleanup temp file: ${tempFile}`);
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
private async moderateVideo(path: string, buffer: Buffer, contentType: string): Promise<void> {
|
|
173
|
-
const tempFile = `/tmp/moderation_${Date.now()}.${this.getFileExtension(contentType)}`;
|
|
174
|
-
writeFileSync(tempFile, buffer);
|
|
175
|
-
|
|
176
|
-
try {
|
|
177
|
-
const result = await this.client.analyzeVideo(tempFile);
|
|
178
|
-
|
|
179
|
-
if (result.nudity?.raw && result.nudity.raw > this.videoNudityThreshold) {
|
|
180
|
-
this.logger.warn(`Video BLOCKED: ${path} - nudity score ${result.nudity.raw.toFixed(2)} > ${this.videoNudityThreshold}`);
|
|
181
|
-
throw new ForbiddenHttpError(`Video contains inappropriate content (nudity: ${result.nudity.raw.toFixed(2)})`);
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
this.logger.info(`Video APPROVED: ${path}`);
|
|
185
|
-
} finally {
|
|
186
|
-
try {
|
|
187
|
-
unlinkSync(tempFile);
|
|
188
|
-
} catch {
|
|
189
|
-
this.logger.warn(`Failed to cleanup temp file: ${tempFile}`);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
private async moderateText(path: string, buffer: Buffer): Promise<void> {
|
|
195
|
-
const text = buffer.toString('utf-8');
|
|
196
|
-
const result = await this.client.analyzeText(text);
|
|
197
|
-
|
|
198
|
-
const violations: string[] = [];
|
|
199
|
-
|
|
200
|
-
if (result.sexual > this.textSexualThreshold) {
|
|
201
|
-
violations.push(`sexual content (${result.sexual.toFixed(2)} > ${this.textSexualThreshold})`);
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (result.toxic > this.textToxicThreshold) {
|
|
205
|
-
violations.push(`toxic content (${result.toxic.toFixed(2)} > ${this.textToxicThreshold})`);
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
if (violations.length > 0) {
|
|
209
|
-
this.logger.warn(`Text BLOCKED: ${path} - ${violations.join(', ')}`);
|
|
210
|
-
throw new ForbiddenHttpError(`Text contains inappropriate content: ${violations.join(', ')}`);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
this.logger.info(`Text APPROVED: ${path}`);
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
private getFileExtension(contentType: string): string {
|
|
217
|
-
const map: Record<string, string> = {
|
|
218
|
-
'image/jpeg': 'jpg',
|
|
219
|
-
'image/png': 'png',
|
|
220
|
-
'image/gif': 'gif',
|
|
221
|
-
'image/webp': 'webp',
|
|
222
|
-
'video/mp4': 'mp4',
|
|
223
|
-
'video/webm': 'webm',
|
|
224
|
-
};
|
|
225
|
-
return map[contentType] || 'bin';
|
|
226
|
-
}
|
|
227
|
-
}
|
package/src/ModerationStore.ts
DELETED
|
@@ -1,41 +0,0 @@
|
|
|
1
|
-
import { promises as fs } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
3
|
-
import { getLoggerFor } from '@solid/community-server';
|
|
4
|
-
import type { ModerationRecord } from './ModerationRecord';
|
|
5
|
-
|
|
6
|
-
/**
|
|
7
|
-
* Service for storing moderation violation records.
|
|
8
|
-
*/
|
|
9
|
-
export class ModerationStore {
|
|
10
|
-
protected readonly logger = getLoggerFor(this);
|
|
11
|
-
|
|
12
|
-
private readonly storePath: string;
|
|
13
|
-
|
|
14
|
-
public constructor(storePath = './data/moderation-logs') {
|
|
15
|
-
this.storePath = storePath;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
public async recordViolation(record: Omit<ModerationRecord, 'id' | 'timestamp'>): Promise<void> {
|
|
19
|
-
try {
|
|
20
|
-
await fs.mkdir(this.storePath, { recursive: true });
|
|
21
|
-
|
|
22
|
-
const fullRecord: ModerationRecord = {
|
|
23
|
-
...record,
|
|
24
|
-
id: this.generateId(),
|
|
25
|
-
timestamp: new Date(),
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const filename = `${new Date().toISOString().split('T')[0]}.jsonl`;
|
|
29
|
-
const filepath = join(this.storePath, filename);
|
|
30
|
-
const logEntry = `${JSON.stringify(fullRecord)}\n`;
|
|
31
|
-
|
|
32
|
-
await fs.appendFile(filepath, logEntry);
|
|
33
|
-
} catch (error: unknown) {
|
|
34
|
-
this.logger.error(`Failed to record moderation violation: ${String(error)}`);
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
private generateId(): string {
|
|
39
|
-
return Date.now().toString(36) + Math.random().toString(36).slice(2);
|
|
40
|
-
}
|
|
41
|
-
}
|
|
@@ -1,101 +0,0 @@
|
|
|
1
|
-
import { getLoggerFor } from '@solid/community-server';
|
|
2
|
-
|
|
3
|
-
const logger = getLoggerFor('GuardedStream');
|
|
4
|
-
|
|
5
|
-
// Using symbols to make sure we don't override existing parameters
|
|
6
|
-
const guardedErrors = Symbol('guardedErrors');
|
|
7
|
-
const guardedTimeout = Symbol('guardedTimeout');
|
|
8
|
-
|
|
9
|
-
// Private fields for guarded streams
|
|
10
|
-
class Guard {
|
|
11
|
-
// Workaround for the fact that we don't initialize this variable as expected
|
|
12
|
-
declare private [guardedErrors]: Error[];
|
|
13
|
-
private [guardedTimeout]?: NodeJS.Timeout;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* A stream that is guarded from emitting errors when there are no listeners.
|
|
18
|
-
* If an error occurs while no listener is attached,
|
|
19
|
-
* it will store the error and emit it once a listener is added (or a timeout occurs).
|
|
20
|
-
*/
|
|
21
|
-
export type Guarded<T extends NodeJS.EventEmitter = NodeJS.EventEmitter> = T & Guard;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Determines whether the stream is guarded against emitting errors.
|
|
25
|
-
*/
|
|
26
|
-
export function isGuarded<T extends NodeJS.EventEmitter>(stream: T): stream is Guarded<T> {
|
|
27
|
-
return typeof (stream as unknown as Guarded)[guardedErrors] === 'object';
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Callback that is used when a stream emits an error and no other error listener is attached.
|
|
32
|
-
* Used to store the error and start the logger timer.
|
|
33
|
-
*
|
|
34
|
-
* It is important that this listener always remains attached for edge cases where an error listener gets removed
|
|
35
|
-
* and the number of error listeners is checked immediately afterwards.
|
|
36
|
-
* See https://github.com/CommunitySolidServer/CommunitySolidServer/pull/462#issuecomment-758013492 .
|
|
37
|
-
*/
|
|
38
|
-
function guardingErrorListener(this: Guarded, error: Error): void {
|
|
39
|
-
// Only fall back to this if no new listeners are attached since guarding started.
|
|
40
|
-
const errorListeners = this.listeners('error');
|
|
41
|
-
if (errorListeners.at(-1) === guardingErrorListener) {
|
|
42
|
-
this[guardedErrors].push(error);
|
|
43
|
-
if (!this[guardedTimeout]) {
|
|
44
|
-
this[guardedTimeout] = setTimeout((): void => {
|
|
45
|
-
logger.error(`No error listener was attached but error was thrown: ${error.message}`);
|
|
46
|
-
}, 1000);
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Callback that is used when a new listener is attached and there are errors that were not emitted yet.
|
|
53
|
-
*/
|
|
54
|
-
function emitStoredErrors(this: Guarded, event: string, func: (error: Error) => void): void {
|
|
55
|
-
if (event === 'error' && func !== guardingErrorListener) {
|
|
56
|
-
// Cancel an error timeout
|
|
57
|
-
if (this[guardedTimeout]) {
|
|
58
|
-
clearTimeout(this[guardedTimeout]);
|
|
59
|
-
this[guardedTimeout] = undefined;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
// Emit any errors that were guarded
|
|
63
|
-
const errors = this[guardedErrors];
|
|
64
|
-
if (errors.length > 0) {
|
|
65
|
-
this[guardedErrors] = [];
|
|
66
|
-
setImmediate((): void => {
|
|
67
|
-
for (const error of errors) {
|
|
68
|
-
this.emit('error', error);
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* Makes sure that listeners always receive the error event of a stream,
|
|
77
|
-
* even if it was thrown before the listener was attached.
|
|
78
|
-
*
|
|
79
|
-
* When guarding a stream it is assumed that error listeners already attached should be ignored,
|
|
80
|
-
* only error listeners attached after the stream is guarded will prevent an error from being logged.
|
|
81
|
-
*
|
|
82
|
-
* If the input is already guarded the guard will be reset,
|
|
83
|
-
* which means ignoring error listeners already attached.
|
|
84
|
-
*
|
|
85
|
-
* @param stream - Stream that can potentially throw an error.
|
|
86
|
-
*
|
|
87
|
-
* @returns The stream.
|
|
88
|
-
*/
|
|
89
|
-
export function guardStream<T extends NodeJS.EventEmitter>(stream: T): Guarded<T> {
|
|
90
|
-
const guarded = stream as Guarded<T>;
|
|
91
|
-
if (isGuarded(stream)) {
|
|
92
|
-
// This makes sure the guarding error listener is the last one in the list again
|
|
93
|
-
guarded.removeListener('error', guardingErrorListener);
|
|
94
|
-
guarded.on('error', guardingErrorListener);
|
|
95
|
-
} else {
|
|
96
|
-
guarded[guardedErrors] = [];
|
|
97
|
-
guarded.on('error', guardingErrorListener);
|
|
98
|
-
guarded.on('newListener', emitStoredErrors);
|
|
99
|
-
}
|
|
100
|
-
return guarded;
|
|
101
|
-
}
|