Repository navigation
Expand file tree
/
Copy pathenvironmentTheme.ts
More file actions
279 lines (252 loc) · 9.93 KB
/
Copy pathenvironmentTheme.ts
File metadata and controls
279 lines (252 loc) · 9.93 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
// @effect-diagnostics nodeBuiltinImport:off - the guarded file read needs open
// flags (O_NOFOLLOW, O_NONBLOCK) the FileSystem service does not expose.
import * as NodeFS from "node:fs";
import {
EnvironmentTheme,
EnvironmentThemeFile,
EnvironmentThemeId,
EnvironmentThemePublishError,
type EnvironmentThemePublishInput,
environmentThemeFileHasColors,
} from "@t3tools/contracts";
import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite";
import { UNPUBLISHABLE_THEME_IDS } from "@t3tools/shared/themePalettes";
import * as Cause from "effect/Cause";
import * as Context from "effect/Context";
import * as Duration from "effect/Duration";
import * as Effect from "effect/Effect";
import * as Equal from "effect/Equal";
import * as Exit from "effect/Exit";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Path from "effect/Path";
import * as PubSub from "effect/PubSub";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Semaphore from "effect/Semaphore";
import * as Scope from "effect/Scope";
import * as Stream from "effect/Stream";
import * as ServerConfig from "./config.ts";
const decodeEnvironmentThemeFileJsonExit = Schema.decodeUnknownExit(
Schema.fromJsonString(EnvironmentThemeFile),
);
const encodeEnvironmentThemeFileJson = Schema.encodeSync(
Schema.fromJsonString(EnvironmentThemeFile),
);
const isEnvironmentThemeId = Schema.is(EnvironmentThemeId);
const THEME_FILE_SUFFIX = ".json";
const MAX_THEME_FILES = 32;
/** Shared with publishing so it cannot accept files the watcher skips. */
export const MAX_THEME_FILE_BYTES = 32 * 1024;
const MAX_THEME_TOTAL_BYTES = 192 * 1024;
interface PublishedThemes {
readonly seq: number;
readonly themes: ReadonlyArray<EnvironmentTheme>;
}
export class EnvironmentThemeService extends Context.Service<
EnvironmentThemeService,
{
readonly current: Effect.Effect<ReadonlyArray<EnvironmentTheme>>;
/** Subscribes before reading to retain changes during connection. */
readonly streamChanges: Stream.Stream<ReadonlyArray<EnvironmentTheme>>;
/** Writes the file the watcher reads, so every connected client receives it. */
readonly publish: (
input: EnvironmentThemePublishInput,
) => Effect.Effect<void, EnvironmentThemePublishError>;
readonly unpublish: (
id: EnvironmentThemeId,
) => Effect.Effect<void, EnvironmentThemePublishError>;
}
>()("t3/environmentTheme/EnvironmentThemeService") {}
/** Validates and reads one handle; rejects symlinked files, allows symlinked directories.
* Uses non-blocking opens against FIFOs; Windows lacks these flags and checks symlinks with lstat. */
export const readThemeFileGuarded = (filePath: string, maxBytes: number): string | null => {
let fd: number;
try {
if (NodeFS.constants.O_NOFOLLOW === undefined && NodeFS.lstatSync(filePath).isSymbolicLink()) {
return null;
}
fd = NodeFS.openSync(
filePath,
NodeFS.constants.O_RDONLY |
(NodeFS.constants.O_NOFOLLOW ?? 0) |
(NodeFS.constants.O_NONBLOCK ?? 0),
);
} catch {
return null;
}
try {
const info = NodeFS.fstatSync(fd);
if (!info.isFile() || info.size > maxBytes) return null;
const contents = Buffer.alloc(info.size);
let offset = 0;
while (offset < contents.length) {
const read = NodeFS.readSync(fd, contents, offset, contents.length - offset, offset);
if (read <= 0) break;
offset += read;
}
return contents.subarray(0, offset).toString("utf8");
} catch {
return null;
} finally {
NodeFS.closeSync(fd);
}
};
export const readPublishedThemes = Effect.fn(function* (themesDir: string) {
const fs = yield* FileSystem.FileSystem;
const entries = yield* fs
.readDirectory(themesDir)
.pipe(Effect.orElseSucceed((): Array<string> => []));
const themes: Array<EnvironmentTheme> = [];
let examined = 0;
let totalBytes = 0;
for (const entry of entries.toSorted()) {
if (!entry.endsWith(THEME_FILE_SUFFIX)) continue;
const id = entry.slice(0, -THEME_FILE_SUFFIX.length);
// Reserved ids shadow built-ins or capture clients that never chose them.
if (!isEnvironmentThemeId(id) || UNPUBLISHABLE_THEME_IDS.has(id)) continue;
// Cap examined files, not accepted themes, to bound work on malformed directories.
examined += 1;
if (examined > MAX_THEME_FILES) {
yield* Effect.logWarning("ignoring environment theme files past the limit", {
path: themesDir,
limit: MAX_THEME_FILES,
});
break;
}
const filePath = `${themesDir}/${entry}`;
const raw = readThemeFileGuarded(filePath, MAX_THEME_FILE_BYTES);
if (raw === null) {
yield* Effect.logWarning("ignoring unusable environment theme file", {
path: filePath,
limit: MAX_THEME_FILE_BYTES,
});
continue;
}
if (raw.trim().length === 0) continue;
const decoded = decodeEnvironmentThemeFileJsonExit(raw);
if (decoded._tag === "Failure") {
yield* Effect.logWarning("ignoring invalid environment theme", {
path: filePath,
detail: Cause.pretty(decoded.cause),
});
continue;
}
const file = decoded.value;
if (!environmentThemeFileHasColors(file)) {
yield* Effect.logWarning("ignoring environment theme without colors", { path: filePath });
continue;
}
totalBytes += Buffer.byteLength(raw);
if (totalBytes > MAX_THEME_TOTAL_BYTES) {
yield* Effect.logWarning("ignoring environment themes past the total size limit", {
path: themesDir,
limit: MAX_THEME_TOTAL_BYTES,
});
break;
}
themes.push({ id, ...file });
}
return themes;
});
const make = Effect.gen(function* () {
const { environmentThemesDir } = yield* ServerConfig.ServerConfig;
const fs = yield* FileSystem.FileSystem;
const path = yield* Path.Path;
// Sequence numbers let subscribers drop queued events older than their initial snapshot.
const changes = yield* PubSub.sliding<PublishedThemes>(1);
const published = yield* Ref.make<PublishedThemes>({ seq: 0, themes: [] });
// Serialize read/compare/publish so slow older reads cannot publish with newer sequence numbers.
const refreshSemaphore = yield* Semaphore.make(1);
const watcherScope = yield* Scope.make("sequential");
yield* Effect.addFinalizer(() => Scope.close(watcherScope, Exit.void));
const refresh = refreshSemaphore.withPermits(1)(
Effect.gen(function* () {
const themes = yield* readPublishedThemes(environmentThemesDir).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
);
// Compare the whole value so new fields cannot silently miss republishes.
const [changed, next] = yield* Ref.modify(
published,
(previous): readonly [readonly [boolean, PublishedThemes], PublishedThemes] => {
if (Equal.equals(previous.themes, themes)) return [[false, previous], previous];
const updated: PublishedThemes = { seq: previous.seq + 1, themes };
return [[true, updated], updated];
},
);
if (changed) yield* PubSub.publish(changes, next).pipe(Effect.asVoid);
return next;
}),
);
yield* fs
.makeDirectory(environmentThemesDir, { recursive: true })
.pipe(Effect.ignoreCause({ log: true }));
// A save emits several events, and fs.watch can fire before content is flushed.
const watchEvents = fs.watch(environmentThemesDir).pipe(Stream.debounce(Duration.millis(100)));
yield* refresh;
yield* Stream.runForEach(watchEvents, () => refresh.pipe(Effect.ignoreCause({ log: true }))).pipe(
Effect.ignoreCause({ log: true }),
Effect.forkIn(watcherScope),
Effect.asVoid,
);
const themePath = (id: EnvironmentThemeId) => path.join(environmentThemesDir, `${id}.json`);
const writeFailed = (cause: unknown) =>
new EnvironmentThemePublishError({ reason: "write-failed", cause });
const publish = Effect.fn("EnvironmentThemeService.publish")(function* (
input: EnvironmentThemePublishInput,
) {
if (UNPUBLISHABLE_THEME_IDS.has(input.id)) {
return yield* new EnvironmentThemePublishError({ reason: "reserved-id" });
}
if (!environmentThemeFileHasColors(input.theme)) {
return yield* new EnvironmentThemePublishError({ reason: "colorless" });
}
const contents = `${encodeEnvironmentThemeFileJson(input.theme)}\n`;
if (Buffer.byteLength(contents) > MAX_THEME_FILE_BYTES) {
return yield* new EnvironmentThemePublishError({ reason: "too-large" });
}
const { themes } = yield* refresh;
if (themes.length >= MAX_THEME_FILES && !themes.some((theme) => theme.id === input.id)) {
return yield* new EnvironmentThemePublishError({ reason: "too-many" });
}
yield* writeFileStringAtomically({ filePath: themePath(input.id), contents }).pipe(
Effect.provideService(FileSystem.FileSystem, fs),
Effect.provideService(Path.Path, path),
Effect.mapError(writeFailed),
);
yield* refresh;
});
const unpublish = Effect.fn("EnvironmentThemeService.unpublish")(function* (
id: EnvironmentThemeId,
) {
yield* fs.remove(themePath(id)).pipe(
Effect.catchIf(
(error) => error.reason._tag === "NotFound",
() => Effect.void,
),
Effect.mapError(writeFailed),
);
yield* refresh;
});
return {
publish,
unpublish,
current: Effect.map(refresh, (state) => state.themes),
get streamChanges() {
return Stream.unwrap(
Effect.gen(function* () {
const subscription = yield* PubSub.subscribe(changes);
const snapshot = yield* refresh;
return Stream.concat(
Stream.make(snapshot.themes),
Stream.fromSubscription(subscription).pipe(
Stream.filter((update) => update.seq > snapshot.seq),
Stream.map((update) => update.themes),
),
);
}),
);
},
} satisfies EnvironmentThemeService["Service"];
});
export const layer = Layer.effect(EnvironmentThemeService, make);