@verbatra/studio 0.1.0-next.1

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/dist/index.js ADDED
@@ -0,0 +1,971 @@
1
+ import { createServer } from 'http';
2
+ import { fileURLToPath } from 'url';
3
+ import { resolve, extname, normalize, join, sep, relative } from 'path';
4
+ import { z } from 'zod';
5
+ import { LOCK_FILE_NAME, lockState, diff, check } from '@verbatra/sdk';
6
+ import { execFile } from 'child_process';
7
+ import { promisify } from 'util';
8
+ import { readFile } from 'fs/promises';
9
+ import { randomBytes, timingSafeEqual, createHash } from 'crypto';
10
+ import { watch } from 'chokidar';
11
+
12
+ // src/server/create-studio-server.ts
13
+
14
+ // src/server/banner.ts
15
+ function buildBanner(url, token) {
16
+ return `verbatra studio listening at ${url}?token=${token}`;
17
+ }
18
+
19
+ // src/server/cookie.ts
20
+ var COOKIE_PREFIX = "verbatra_studio_";
21
+ function cookieName(port) {
22
+ return `${COOKIE_PREFIX}${port}`;
23
+ }
24
+ function splitCookiePairs(header) {
25
+ return header.split(";").map((pair) => pair.trim());
26
+ }
27
+ function readCookieValue(header, name) {
28
+ if (header === void 0) {
29
+ return void 0;
30
+ }
31
+ for (const pair of splitCookiePairs(header)) {
32
+ const separatorIndex = pair.indexOf("=");
33
+ if (separatorIndex === -1) {
34
+ continue;
35
+ }
36
+ if (pair.slice(0, separatorIndex) === name) {
37
+ return pair.slice(separatorIndex + 1);
38
+ }
39
+ }
40
+ return void 0;
41
+ }
42
+ function buildSetCookieHeader(name, value) {
43
+ return `${name}=${value}; Path=/; HttpOnly; SameSite=Strict`;
44
+ }
45
+
46
+ // src/server/default-port.ts
47
+ var DEFAULT_STUDIO_PORT = 5849;
48
+ function resolvePort(port) {
49
+ return port ?? DEFAULT_STUDIO_PORT;
50
+ }
51
+
52
+ // src/server/body-reader.ts
53
+ var BODY_CAP_BYTES = 1024 * 1024;
54
+ var PayloadTooLargeError = class extends Error {
55
+ constructor() {
56
+ super("request body exceeds the size cap");
57
+ this.name = "PayloadTooLargeError";
58
+ }
59
+ };
60
+ function declaredLengthExceedsCap(request, capBytes) {
61
+ const header = request.headers["content-length"];
62
+ if (header === void 0) {
63
+ return false;
64
+ }
65
+ const declared = Number(header);
66
+ return Number.isFinite(declared) && declared > capBytes;
67
+ }
68
+ function readBodyWithCap(request, capBytes) {
69
+ return new Promise((resolve3, reject) => {
70
+ if (declaredLengthExceedsCap(request, capBytes)) {
71
+ request.resume();
72
+ reject(new PayloadTooLargeError());
73
+ return;
74
+ }
75
+ const chunks = [];
76
+ let total = 0;
77
+ let settled = false;
78
+ const settleReject = (error) => {
79
+ if (!settled) {
80
+ settled = true;
81
+ reject(error);
82
+ }
83
+ };
84
+ request.on("data", (chunk) => {
85
+ total += chunk.length;
86
+ if (total > capBytes) {
87
+ settled = true;
88
+ reject(new PayloadTooLargeError());
89
+ request.destroy();
90
+ return;
91
+ }
92
+ chunks.push(chunk);
93
+ });
94
+ request.on("end", () => {
95
+ settled = true;
96
+ resolve3(Buffer.concat(chunks));
97
+ });
98
+ request.on("error", settleReject);
99
+ request.on(
100
+ "close",
101
+ () => settleReject(new Error("request closed before the body was fully received"))
102
+ );
103
+ });
104
+ }
105
+ var CONTENT_TYPES = {
106
+ ".html": "text/html; charset=utf-8",
107
+ ".js": "text/javascript; charset=utf-8",
108
+ ".mjs": "text/javascript; charset=utf-8",
109
+ ".css": "text/css; charset=utf-8",
110
+ ".json": "application/json; charset=utf-8",
111
+ ".svg": "image/svg+xml",
112
+ ".png": "image/png",
113
+ ".ico": "image/x-icon"
114
+ };
115
+ function contentTypeFor(assetPath) {
116
+ return CONTENT_TYPES[extname(assetPath)] ?? "application/octet-stream";
117
+ }
118
+
119
+ // src/server/host-origin.ts
120
+ function isAllowedHost(hostHeader, port) {
121
+ if (hostHeader === void 0) {
122
+ return false;
123
+ }
124
+ return hostHeader.toLowerCase() === `127.0.0.1:${port}`;
125
+ }
126
+ function isAllowedOrigin(originHeader, port) {
127
+ if (originHeader === void 0) {
128
+ return true;
129
+ }
130
+ return originHeader === `http://127.0.0.1:${port}`;
131
+ }
132
+
133
+ // src/server/request-content-type.ts
134
+ function isJsonRequestContentType(header) {
135
+ if (header === void 0) {
136
+ return false;
137
+ }
138
+ return header.trim().toLowerCase() === "application/json";
139
+ }
140
+
141
+ // src/server/request-log.ts
142
+ function formatRequestLog(entry) {
143
+ return `${entry.method} ${entry.path} ${entry.status}`;
144
+ }
145
+ var STATUS_CHECK_METHOD = "status.check";
146
+ var statusCheckParamsSchema = z.strictObject({
147
+ locales: z.array(z.string().min(1)).min(1).optional()
148
+ });
149
+ var STATUS_DIFF_METHOD = "status.diff";
150
+ var statusDiffParamsSchema = z.strictObject({
151
+ locales: z.array(z.string().min(1)).min(1).optional()
152
+ });
153
+ var GLOSSARY_GET_METHOD = "glossary.get";
154
+ var glossaryGetParamsSchema = z.strictObject({});
155
+ var HISTORY_LIST_METHOD = "history.list";
156
+ var historyListParamsSchema = z.strictObject({
157
+ limit: z.number().int().positive().optional()
158
+ });
159
+ var LOCK_STATE_METHOD = "lock.state";
160
+ var lockStateParamsSchema = z.strictObject({});
161
+ var PROJECT_SNAPSHOT_METHOD = "project.snapshot";
162
+ var projectSnapshotParamsSchema = z.strictObject({});
163
+
164
+ // src/shared/rpc/contract.ts
165
+ var rpcParamsSchemas = {
166
+ [PROJECT_SNAPSHOT_METHOD]: projectSnapshotParamsSchema,
167
+ [STATUS_CHECK_METHOD]: statusCheckParamsSchema,
168
+ [STATUS_DIFF_METHOD]: statusDiffParamsSchema,
169
+ [GLOSSARY_GET_METHOD]: glossaryGetParamsSchema,
170
+ [LOCK_STATE_METHOD]: lockStateParamsSchema,
171
+ [HISTORY_LIST_METHOD]: historyListParamsSchema
172
+ };
173
+ var RPC_METHOD_NAMES = Object.keys(rpcParamsSchemas);
174
+
175
+ // src/server/redaction.ts
176
+ var REDACTED = "[REDACTED]";
177
+ var KEY_PATTERNS = [
178
+ // The `\b` anchors `sk-` to a word start so hyphenated words like "risk-" or "task-" pass through.
179
+ /\bsk-[A-Za-z0-9_-]{8,}/g,
180
+ /AIza[0-9A-Za-z_-]{35}/g,
181
+ /[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}(?::fx)?/g
182
+ ];
183
+ var PROVIDER_ENV_VAR_NAMES = [
184
+ "ANTHROPIC_API_KEY",
185
+ "OPENAI_API_KEY",
186
+ "GEMINI_API_KEY",
187
+ "DEEPL_API_KEY"
188
+ ];
189
+ function escapeForRegExp(value) {
190
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
191
+ }
192
+ function scrubConfiguredEnvValues(text) {
193
+ let out = text;
194
+ for (const name of PROVIDER_ENV_VAR_NAMES) {
195
+ const value = process.env[name];
196
+ if (value !== void 0 && value.length > 0) {
197
+ out = out.replace(new RegExp(escapeForRegExp(value), "g"), REDACTED);
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+ function redact(text) {
203
+ let out = text;
204
+ for (const pattern of KEY_PATTERNS) {
205
+ out = out.replace(pattern, REDACTED);
206
+ }
207
+ return scrubConfiguredEnvValues(out);
208
+ }
209
+ var statusCheckHandler = async (params, deps) => check({
210
+ config: deps.config.config,
211
+ cwd: deps.projectRoot,
212
+ ...params.locales !== void 0 ? { locales: params.locales } : {}
213
+ });
214
+ var statusDiffHandler = async (params, deps) => diff({
215
+ config: deps.config.config,
216
+ cwd: deps.projectRoot,
217
+ ...params.locales !== void 0 ? { locales: params.locales } : {}
218
+ });
219
+ function projectConfigSource(source, projectRoot) {
220
+ if (source.kind === "override") {
221
+ return "override";
222
+ }
223
+ return redact(relative(projectRoot, source.filepath));
224
+ }
225
+ function projectGlossaryIndicator(glossary, projectRoot) {
226
+ if (glossary.source === "file") {
227
+ return { source: "file", path: redact(relative(projectRoot, glossary.path)) };
228
+ }
229
+ return { source: glossary.source };
230
+ }
231
+ function buildProjectSnapshot(loaded, projectRoot) {
232
+ const { config } = loaded;
233
+ return {
234
+ sourceLocale: redact(config.sourceLocale),
235
+ targetLocales: config.targetLocales.map((locale) => redact(locale)),
236
+ format: config.format,
237
+ files: { pattern: redact(config.files.pattern) },
238
+ provider: { id: config.provider.id },
239
+ configSource: projectConfigSource(loaded.source, projectRoot),
240
+ glossary: projectGlossaryIndicator(loaded.glossary, projectRoot),
241
+ ...config.prune !== void 0 ? { prune: config.prune } : {},
242
+ ...config.generatePlurals !== void 0 ? { generatePlurals: config.generatePlurals } : {},
243
+ ...config.maxBatchSize !== void 0 ? { maxBatchSize: config.maxBatchSize } : {},
244
+ ...config.tone !== void 0 ? { tone: config.tone } : {}
245
+ };
246
+ }
247
+
248
+ // src/server/methods/glossary.ts
249
+ function redactGlossaryEntries(glossary) {
250
+ if (glossary === void 0) {
251
+ return {};
252
+ }
253
+ const redacted = {};
254
+ for (const [key, value] of Object.entries(glossary)) {
255
+ redacted[key] = redact(value);
256
+ }
257
+ return redacted;
258
+ }
259
+ var glossaryGetHandler = async (_params, deps) => ({
260
+ indicator: projectGlossaryIndicator(deps.config.glossary, deps.projectRoot),
261
+ entries: redactGlossaryEntries(deps.config.config.glossary)
262
+ });
263
+ function withoutTrailingSep(path) {
264
+ return path.length > sep.length && path.endsWith(sep) ? path.slice(0, -sep.length) : path;
265
+ }
266
+
267
+ // src/server/git.ts
268
+ var execFileAsync = promisify(execFile);
269
+ var defaultExecFileImpl = async (file, args, options) => {
270
+ const { stdout, stderr } = await execFileAsync(file, args, {
271
+ cwd: options.cwd,
272
+ encoding: "utf8"
273
+ });
274
+ return { stdout, stderr };
275
+ };
276
+ var HISTORY_LIMIT_DEFAULT = 50;
277
+ var HISTORY_LIMIT_CAP = 200;
278
+ function clampHistoryLimit(limit) {
279
+ return Math.min(limit ?? HISTORY_LIMIT_DEFAULT, HISTORY_LIMIT_CAP);
280
+ }
281
+ function isPathContained(root, candidate) {
282
+ const normalizedRoot = withoutTrailingSep(root);
283
+ return candidate === normalizedRoot || candidate.startsWith(normalizedRoot + sep);
284
+ }
285
+ function hasLeadingDash(path) {
286
+ return path.startsWith("-");
287
+ }
288
+ function resolveWatchedPaths(projectRoot, candidates) {
289
+ const root = resolve(projectRoot);
290
+ const safe = candidates.filter((candidate) => !hasLeadingDash(candidate)).map((candidate) => resolve(root, candidate)).filter((candidate) => isPathContained(root, candidate));
291
+ return Array.from(new Set(safe));
292
+ }
293
+ var RECORD_SEPARATOR = "";
294
+ var FIELD_SEPARATOR = "";
295
+ var GIT_LOG_FORMAT = `${RECORD_SEPARATOR}%H${FIELD_SEPARATOR}%aI${FIELD_SEPARATOR}%s`;
296
+ function buildGitLogArgs(maxCount, paths) {
297
+ return [
298
+ "log",
299
+ `--max-count=${maxCount}`,
300
+ "--name-only",
301
+ "-z",
302
+ `--format=${GIT_LOG_FORMAT}`,
303
+ "--",
304
+ ...paths
305
+ ];
306
+ }
307
+ function parseCommitHeader(header) {
308
+ const [hash, authorDate, subject] = header.split(FIELD_SEPARATOR);
309
+ if (hash === void 0 || authorDate === void 0 || subject === void 0) {
310
+ return void 0;
311
+ }
312
+ return { hash, authorDate, subject };
313
+ }
314
+ function parseTouchedPaths(filesPart) {
315
+ return filesPart.split("\0").map((entry) => entry.startsWith("\n") ? entry.slice(1) : entry).filter((entry) => entry.length > 0);
316
+ }
317
+ function parseCommitRecord(record) {
318
+ const nulIndex = record.indexOf("\0");
319
+ const header = nulIndex === -1 ? record : record.slice(0, nulIndex);
320
+ const parsedHeader = parseCommitHeader(header);
321
+ if (parsedHeader === void 0) {
322
+ return void 0;
323
+ }
324
+ const touchedPaths = nulIndex === -1 ? [] : parseTouchedPaths(record.slice(nulIndex + 1));
325
+ return { ...parsedHeader, touchedPaths };
326
+ }
327
+ function parseGitLogOutput(stdout) {
328
+ return stdout.split(RECORD_SEPARATOR).filter((record) => record.length > 0).map(parseCommitRecord).filter((commit) => commit !== void 0);
329
+ }
330
+ function isMissingGitBinary(error) {
331
+ return error.code === "ENOENT";
332
+ }
333
+ function isNotARepository(error) {
334
+ return typeof error.stderr === "string" && error.stderr.includes("not a git repository");
335
+ }
336
+ function interpretGitLogFailure(error) {
337
+ const failure = error;
338
+ if (isMissingGitBinary(failure) || isNotARepository(failure)) {
339
+ return { available: false };
340
+ }
341
+ return { available: true, commits: [] };
342
+ }
343
+ async function runGitLog(input) {
344
+ if (input.watchedPaths.length === 0) {
345
+ return { available: true, commits: [] };
346
+ }
347
+ const args = buildGitLogArgs(clampHistoryLimit(input.limit), input.watchedPaths);
348
+ try {
349
+ const { stdout } = await input.execFileImpl("git", args, { cwd: input.projectRoot });
350
+ return { available: true, commits: parseGitLogOutput(stdout) };
351
+ } catch (error) {
352
+ return interpretGitLogFailure(error);
353
+ }
354
+ }
355
+ var LOCALE_TOKEN = "{locale}";
356
+ function localeFilePath(cwd, pattern, locale) {
357
+ return resolve(cwd, pattern.replaceAll(LOCALE_TOKEN, locale));
358
+ }
359
+
360
+ // src/server/methods/history.ts
361
+ function watchedLocalePaths(config, projectRoot) {
362
+ const locales = [config.sourceLocale, ...config.targetLocales];
363
+ return locales.map((locale) => localeFilePath(projectRoot, config.files.pattern, locale));
364
+ }
365
+ var historyListHandler = async (params, deps) => {
366
+ const execFileImpl = deps.execFileImpl ?? defaultExecFileImpl;
367
+ const candidates = watchedLocalePaths(deps.config.config, deps.projectRoot);
368
+ const watchedPaths = resolveWatchedPaths(deps.projectRoot, candidates);
369
+ return runGitLog({
370
+ execFileImpl,
371
+ projectRoot: deps.projectRoot,
372
+ watchedPaths,
373
+ ...params.limit !== void 0 ? { limit: params.limit } : {}
374
+ });
375
+ };
376
+ var lockStateHandler = async (_params, deps) => lockState({ config: deps.config.config, cwd: deps.projectRoot });
377
+
378
+ // src/server/methods/snapshot.ts
379
+ var snapshotHandler = async (_params, deps) => buildProjectSnapshot(deps.config, deps.projectRoot);
380
+
381
+ // src/server/rpc.ts
382
+ var rpcHandlers = {
383
+ [PROJECT_SNAPSHOT_METHOD]: snapshotHandler,
384
+ [STATUS_CHECK_METHOD]: statusCheckHandler,
385
+ [STATUS_DIFF_METHOD]: statusDiffHandler,
386
+ [GLOSSARY_GET_METHOD]: glossaryGetHandler,
387
+ [LOCK_STATE_METHOD]: lockStateHandler,
388
+ [HISTORY_LIST_METHOD]: historyListHandler
389
+ };
390
+
391
+ // src/server/rpc-gate.ts
392
+ var REQUEST_INVALID_MESSAGE = "The request body must be JSON shaped as { method, params }.";
393
+ var METHOD_UNKNOWN_MESSAGE = "The requested method is not recognized.";
394
+ var PARAMS_INVALID_MESSAGE = "The request parameters failed validation.";
395
+ var INTERNAL_ERROR_MESSAGE = "An unexpected error occurred.";
396
+ function isPlainObject(value) {
397
+ return typeof value === "object" && value !== null && !Array.isArray(value);
398
+ }
399
+ function parseRequestShape(body) {
400
+ let parsed;
401
+ try {
402
+ parsed = JSON.parse(body.toString("utf8"));
403
+ } catch {
404
+ return void 0;
405
+ }
406
+ if (!isPlainObject(parsed)) {
407
+ return void 0;
408
+ }
409
+ const method = parsed.method;
410
+ if (typeof method !== "string") {
411
+ return void 0;
412
+ }
413
+ return { method, params: parsed.params };
414
+ }
415
+ function isKnownMethod(method) {
416
+ return RPC_METHOD_NAMES.includes(method);
417
+ }
418
+ function toParsedIssues(error) {
419
+ return error.issues.map((issue) => ({ path: issue.path.map(String), code: issue.code }));
420
+ }
421
+ function jsonEnvelope(statusCode, body) {
422
+ return { statusCode, body: JSON.stringify(body) };
423
+ }
424
+ function okEnvelope(result) {
425
+ return jsonEnvelope(200, { ok: true, result });
426
+ }
427
+ function errorEnvelope(statusCode, code, message, issues) {
428
+ const error = issues === void 0 ? { code, message } : { code, message, issues };
429
+ return jsonEnvelope(statusCode, { ok: false, error });
430
+ }
431
+ function isDomainError(error) {
432
+ return error instanceof Error && (error.name === "SdkError" || error.name === "AdapterError") && typeof error.code === "string";
433
+ }
434
+ function mapHandlerError(error) {
435
+ if (isDomainError(error)) {
436
+ return jsonEnvelope(200, {
437
+ ok: false,
438
+ error: { code: error.code, message: redact(error.message) }
439
+ });
440
+ }
441
+ return errorEnvelope(500, "INTERNAL", INTERNAL_ERROR_MESSAGE);
442
+ }
443
+ async function invokeHandler(method, params, deps, handlers) {
444
+ const schema = rpcParamsSchemas[method];
445
+ const parsedParams = schema.safeParse(params);
446
+ if (!parsedParams.success) {
447
+ return errorEnvelope(
448
+ 400,
449
+ "PARAMS_INVALID",
450
+ PARAMS_INVALID_MESSAGE,
451
+ toParsedIssues(parsedParams.error)
452
+ );
453
+ }
454
+ const handler = handlers[method];
455
+ if (handler === void 0) {
456
+ return errorEnvelope(400, "METHOD_UNKNOWN", METHOD_UNKNOWN_MESSAGE);
457
+ }
458
+ try {
459
+ const result = await handler(parsedParams.data, deps);
460
+ return okEnvelope(result);
461
+ } catch (error) {
462
+ return mapHandlerError(error);
463
+ }
464
+ }
465
+ async function dispatchRpc(body, deps, handlers = rpcHandlers) {
466
+ const request = parseRequestShape(body);
467
+ if (request === void 0) {
468
+ return errorEnvelope(400, "REQUEST_INVALID", REQUEST_INVALID_MESSAGE);
469
+ }
470
+ if (!isKnownMethod(request.method)) {
471
+ return errorEnvelope(400, "METHOD_UNKNOWN", METHOD_UNKNOWN_MESSAGE);
472
+ }
473
+ return invokeHandler(request.method, request.params, deps, handlers);
474
+ }
475
+ function handleRpcBody(body, deps) {
476
+ return dispatchRpc(body, deps);
477
+ }
478
+
479
+ // src/server/security-headers.ts
480
+ var CONTENT_SECURITY_POLICY = "default-src 'none'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'";
481
+ function applySecurityHeaders(response) {
482
+ response.setHeader("X-Content-Type-Options", "nosniff");
483
+ response.setHeader("Referrer-Policy", "no-referrer");
484
+ response.setHeader("X-Frame-Options", "DENY");
485
+ response.setHeader("Content-Security-Policy", CONTENT_SECURITY_POLICY);
486
+ }
487
+ function applyNoStore(response) {
488
+ response.setHeader("Cache-Control", "no-store");
489
+ }
490
+ function decodeRequestPath(requestPath) {
491
+ try {
492
+ return decodeURIComponent(requestPath);
493
+ } catch {
494
+ return requestPath;
495
+ }
496
+ }
497
+ function hasDotSegment(pathWithoutLeadingSlash) {
498
+ return pathWithoutLeadingSlash.split("/").some((segment) => segment.startsWith("."));
499
+ }
500
+ function stripQuery(requestPath) {
501
+ const queryIndex = requestPath.indexOf("?");
502
+ return queryIndex === -1 ? requestPath : requestPath.slice(0, queryIndex);
503
+ }
504
+ function resolveAssetPath(assetsRootPath, requestPath) {
505
+ const root = withoutTrailingSep(normalize(assetsRootPath));
506
+ const decoded = decodeRequestPath(stripQuery(requestPath));
507
+ const withoutLeadingSlash = decoded.replace(/^\/+/, "");
508
+ if (hasDotSegment(withoutLeadingSlash)) {
509
+ return void 0;
510
+ }
511
+ const candidate = normalize(join(root, withoutLeadingSlash || "index.html"));
512
+ return candidate.startsWith(root + sep) ? candidate : void 0;
513
+ }
514
+ async function readAsset(assetsRootPath, requestPath) {
515
+ const resolved = resolveAssetPath(assetsRootPath, requestPath);
516
+ if (resolved === void 0) {
517
+ return void 0;
518
+ }
519
+ try {
520
+ const body = await readFile(resolved);
521
+ return { path: resolved, body };
522
+ } catch {
523
+ return void 0;
524
+ }
525
+ }
526
+ var TOKEN_BYTES = 32;
527
+ function generateToken() {
528
+ return randomBytes(TOKEN_BYTES).toString("hex");
529
+ }
530
+ function hashToken(value) {
531
+ return createHash("sha256").update(value, "utf8").digest();
532
+ }
533
+ function tokensMatch(candidate, stored) {
534
+ return timingSafeEqual(hashToken(candidate), hashToken(stored));
535
+ }
536
+
537
+ // src/server/transport-responses.ts
538
+ var UNAUTHORIZED_BODY = "Unauthorized";
539
+ var FORBIDDEN_BODY = "Forbidden";
540
+ var NOT_FOUND_BODY = "Not Found";
541
+ var METHOD_NOT_ALLOWED_BODY = "Method Not Allowed";
542
+ var PAYLOAD_TOO_LARGE_BODY = "Payload Too Large";
543
+ var UNSUPPORTED_MEDIA_TYPE_BODY = "Unsupported Media Type";
544
+ function sendConstantResponse(response, status, body) {
545
+ applyNoStore(response);
546
+ response.statusCode = status;
547
+ response.setHeader("Content-Type", "text/plain; charset=utf-8");
548
+ response.end(body);
549
+ }
550
+
551
+ // src/server/dispatch.ts
552
+ var EVENTS_PATH = "/events";
553
+ function pathWithoutQuery(url) {
554
+ const index = url.indexOf("?");
555
+ return index === -1 ? url : url.slice(0, index);
556
+ }
557
+ function extractBootstrapToken(url) {
558
+ const index = url.indexOf("?");
559
+ if (index === -1) {
560
+ return void 0;
561
+ }
562
+ const params = new URLSearchParams(url.slice(index + 1));
563
+ return params.has("token") ? params.get("token") ?? "" : void 0;
564
+ }
565
+ function isAuthenticated(context, request) {
566
+ const cookieValue = readCookieValue(request.headers.cookie, context.cookieName);
567
+ return cookieValue !== void 0 && tokensMatch(cookieValue, context.token);
568
+ }
569
+ function finishConstant(context, response, method, path, status, body) {
570
+ sendConstantResponse(response, status, body);
571
+ context.log(formatRequestLog({ method, path, status }));
572
+ }
573
+ function sendRedirectToRoot(context, response, method, path) {
574
+ applyNoStore(response);
575
+ response.statusCode = 303;
576
+ response.setHeader("Location", "/");
577
+ response.end();
578
+ context.log(formatRequestLog({ method, path, status: 303 }));
579
+ }
580
+ function handleBootstrap(context, response, method, path, candidate) {
581
+ if (!tokensMatch(candidate, context.token)) {
582
+ finishConstant(context, response, method, path, 401, UNAUTHORIZED_BODY);
583
+ return;
584
+ }
585
+ response.setHeader("Set-Cookie", buildSetCookieHeader(context.cookieName, context.token));
586
+ sendRedirectToRoot(context, response, method, path);
587
+ }
588
+ async function serveStatic(context, response, method, path) {
589
+ const asset = await readAsset(context.assetsRootPath, path) ?? await readAsset(context.assetsRootPath, "/index.html");
590
+ if (asset === void 0) {
591
+ finishConstant(context, response, method, path, 404, NOT_FOUND_BODY);
592
+ return;
593
+ }
594
+ const contentType = contentTypeFor(asset.path);
595
+ if (contentType.startsWith("text/html")) {
596
+ applyNoStore(response);
597
+ }
598
+ response.statusCode = 200;
599
+ response.setHeader("Content-Type", contentType);
600
+ response.end(asset.body);
601
+ context.log(formatRequestLog({ method, path, status: 200 }));
602
+ }
603
+ function handleEvents(context, response, method, path) {
604
+ applyNoStore(response);
605
+ response.writeHead(200, { "Content-Type": "text/event-stream; charset=utf-8" });
606
+ response.write(": connected\n\n");
607
+ context.sseHub.register(response);
608
+ context.log(formatRequestLog({ method, path, status: 200 }));
609
+ }
610
+ async function handleGet(context, request, response, method, path) {
611
+ const bootstrapToken = path === "/" ? extractBootstrapToken(request.url ?? "/") : void 0;
612
+ if (bootstrapToken !== void 0) {
613
+ handleBootstrap(context, response, method, path, bootstrapToken);
614
+ return;
615
+ }
616
+ if (!isAuthenticated(context, request)) {
617
+ finishConstant(context, response, method, path, 401, UNAUTHORIZED_BODY);
618
+ return;
619
+ }
620
+ if (path === EVENTS_PATH) {
621
+ handleEvents(context, response, method, path);
622
+ return;
623
+ }
624
+ await serveStatic(context, response, method, path);
625
+ }
626
+ async function readRpcBody(context, request, response, method, path) {
627
+ try {
628
+ return await readBodyWithCap(request, BODY_CAP_BYTES);
629
+ } catch (error) {
630
+ if (error instanceof PayloadTooLargeError) {
631
+ finishConstant(context, response, method, path, 413, PAYLOAD_TOO_LARGE_BODY);
632
+ return void 0;
633
+ }
634
+ throw error;
635
+ }
636
+ }
637
+ async function handlePost(context, request, response, method, path) {
638
+ if (!isAllowedOrigin(request.headers.origin, context.port)) {
639
+ finishConstant(context, response, method, path, 403, FORBIDDEN_BODY);
640
+ return;
641
+ }
642
+ if (path !== "/rpc") {
643
+ finishConstant(context, response, method, path, 404, NOT_FOUND_BODY);
644
+ return;
645
+ }
646
+ if (!isAuthenticated(context, request)) {
647
+ finishConstant(context, response, method, path, 401, UNAUTHORIZED_BODY);
648
+ return;
649
+ }
650
+ if (!isJsonRequestContentType(request.headers["content-type"])) {
651
+ finishConstant(context, response, method, path, 415, UNSUPPORTED_MEDIA_TYPE_BODY);
652
+ return;
653
+ }
654
+ const body = await readRpcBody(context, request, response, method, path);
655
+ if (body === void 0) {
656
+ return;
657
+ }
658
+ const result = await handleRpcBody(body, context.rpcDeps);
659
+ applyNoStore(response);
660
+ response.statusCode = result.statusCode;
661
+ response.setHeader("Content-Type", "application/json; charset=utf-8");
662
+ response.end(result.body);
663
+ context.log(formatRequestLog({ method, path, status: result.statusCode }));
664
+ }
665
+ async function handleRequest(context, request, response) {
666
+ const method = request.method ?? "GET";
667
+ const path = pathWithoutQuery(request.url ?? "/");
668
+ applySecurityHeaders(response);
669
+ if (!isAllowedHost(request.headers.host, context.port)) {
670
+ finishConstant(context, response, method, path, 403, FORBIDDEN_BODY);
671
+ return;
672
+ }
673
+ if (method === "OPTIONS") {
674
+ finishConstant(context, response, method, path, 403, FORBIDDEN_BODY);
675
+ return;
676
+ }
677
+ if (method === "GET") {
678
+ await handleGet(context, request, response, method, path);
679
+ return;
680
+ }
681
+ if (method === "POST") {
682
+ await handlePost(context, request, response, method, path);
683
+ return;
684
+ }
685
+ finishConstant(context, response, method, path, 405, METHOD_NOT_ALLOWED_BODY);
686
+ }
687
+
688
+ // src/server/errors.ts
689
+ var StudioServerStartError = class extends Error {
690
+ code;
691
+ port;
692
+ constructor(code, port, message) {
693
+ super(message);
694
+ this.name = "StudioServerStartError";
695
+ this.code = code;
696
+ this.port = port;
697
+ }
698
+ };
699
+
700
+ // src/server/resolve-bound-port.ts
701
+ function resolveBoundAddress(address) {
702
+ if (address === null || typeof address === "string") {
703
+ throw new Error("verbatra studio server failed to bind a TCP address");
704
+ }
705
+ return address;
706
+ }
707
+
708
+ // src/shared/sse-events.ts
709
+ var SSE_EVENT_REFRESH = "refresh";
710
+ var SSE_EVENT_SHUTDOWN = "shutdown";
711
+
712
+ // src/server/sse.ts
713
+ var DEFAULT_HEARTBEAT_MS = 15e3;
714
+ function tryWrite(response, event, data) {
715
+ const frame = redact(`event: ${event}
716
+ data: ${JSON.stringify(data)}
717
+
718
+ `);
719
+ try {
720
+ return response.write(frame);
721
+ } catch {
722
+ return false;
723
+ }
724
+ }
725
+ function tryWriteHeartbeat(response) {
726
+ try {
727
+ return response.write(`: heartbeat ${Date.now()}
728
+
729
+ `);
730
+ } catch {
731
+ return false;
732
+ }
733
+ }
734
+ function tryEnd(response) {
735
+ try {
736
+ response.end();
737
+ } catch {
738
+ }
739
+ }
740
+ function createSseHub(options = {}) {
741
+ const clients = /* @__PURE__ */ new Set();
742
+ function deregister(response) {
743
+ clients.delete(response);
744
+ }
745
+ function register(response) {
746
+ clients.add(response);
747
+ response.once("close", () => deregister(response));
748
+ response.once("error", () => deregister(response));
749
+ }
750
+ function broadcastRefresh(event) {
751
+ for (const response of clients) {
752
+ if (!tryWrite(response, SSE_EVENT_REFRESH, event)) {
753
+ deregister(response);
754
+ }
755
+ }
756
+ }
757
+ function tickHeartbeat() {
758
+ for (const response of clients) {
759
+ if (!tryWriteHeartbeat(response)) {
760
+ deregister(response);
761
+ }
762
+ }
763
+ }
764
+ const heartbeatTimer = setInterval(
765
+ tickHeartbeat,
766
+ options.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_MS
767
+ );
768
+ heartbeatTimer.unref?.();
769
+ function closeAll() {
770
+ clearInterval(heartbeatTimer);
771
+ const shutdownEvent = { at: (/* @__PURE__ */ new Date()).toISOString() };
772
+ for (const response of clients) {
773
+ tryWrite(response, SSE_EVENT_SHUTDOWN, shutdownEvent);
774
+ tryEnd(response);
775
+ }
776
+ clients.clear();
777
+ }
778
+ return {
779
+ register,
780
+ broadcastRefresh,
781
+ closeAll,
782
+ get size() {
783
+ return clients.size;
784
+ }
785
+ };
786
+ }
787
+ var DEFAULT_DEBOUNCE_MS = 300;
788
+ function watchedCategories(config, projectRoot) {
789
+ const source = localeFilePath(projectRoot, config.files.pattern, config.sourceLocale);
790
+ const targets = config.targetLocales.map(
791
+ (locale) => localeFilePath(projectRoot, config.files.pattern, locale)
792
+ );
793
+ const lock = resolve(projectRoot, LOCK_FILE_NAME);
794
+ const categories = [{ reason: "source", paths: [source] }];
795
+ if (targets.length > 0) {
796
+ categories.push({ reason: "targets", paths: targets });
797
+ }
798
+ categories.push({ reason: "lock", paths: [lock] });
799
+ return categories;
800
+ }
801
+ function createDebouncedTrigger(reason, debounceMs, emit) {
802
+ let timer;
803
+ return {
804
+ trigger() {
805
+ if (timer !== void 0) {
806
+ clearTimeout(timer);
807
+ }
808
+ timer = setTimeout(() => {
809
+ timer = void 0;
810
+ emit({ reason, at: (/* @__PURE__ */ new Date()).toISOString() });
811
+ }, debounceMs);
812
+ },
813
+ clear() {
814
+ if (timer !== void 0) {
815
+ clearTimeout(timer);
816
+ timer = void 0;
817
+ }
818
+ }
819
+ };
820
+ }
821
+ function createProjectWatcher(input, deps) {
822
+ const debounceMs = input.debounceMs ?? DEFAULT_DEBOUNCE_MS;
823
+ const listeners = /* @__PURE__ */ new Set();
824
+ function emit(event) {
825
+ for (const listener of listeners) {
826
+ listener(event);
827
+ }
828
+ }
829
+ const entries = watchedCategories(input.config, input.projectRoot).map((category) => {
830
+ const debounced = createDebouncedTrigger(category.reason, debounceMs, emit);
831
+ const watcher = deps.createWatcher(category.paths);
832
+ watcher.onChange(debounced.trigger);
833
+ return { debounced, watcher };
834
+ });
835
+ return {
836
+ onRefresh(listener) {
837
+ listeners.add(listener);
838
+ },
839
+ async close() {
840
+ for (const entry of entries) {
841
+ entry.debounced.clear();
842
+ }
843
+ await Promise.all(entries.map((entry) => entry.watcher.close()));
844
+ }
845
+ };
846
+ }
847
+ var defaultCreateStudioWatcher = (paths) => {
848
+ const fsWatcher = watch([...paths], { persistent: true, ignoreInitial: true });
849
+ return {
850
+ onChange(listener) {
851
+ fsWatcher.on("change", () => listener());
852
+ fsWatcher.on("add", () => listener());
853
+ },
854
+ close: () => fsWatcher.close()
855
+ };
856
+ };
857
+
858
+ // src/server/create-studio-server.ts
859
+ var RAW_FORBIDDEN_RESPONSE = [
860
+ "HTTP/1.1 403 Forbidden",
861
+ "Content-Type: text/plain; charset=utf-8",
862
+ `Content-Length: ${Buffer.byteLength(FORBIDDEN_BODY)}`,
863
+ "Connection: close",
864
+ "",
865
+ FORBIDDEN_BODY
866
+ ].join("\r\n");
867
+ function handleClientError(_error, socket) {
868
+ if (socket.writable) {
869
+ socket.end(RAW_FORBIDDEN_RESPONSE);
870
+ } else {
871
+ socket.destroy();
872
+ }
873
+ }
874
+ function defaultAssetsRoot() {
875
+ return new URL("./app/", import.meta.url);
876
+ }
877
+ function defaultOutput(line) {
878
+ console.log(line);
879
+ }
880
+ function buildRpcHandlerDeps(config, projectRoot, options) {
881
+ return {
882
+ config,
883
+ projectRoot,
884
+ ...options.fs !== void 0 ? { fs: options.fs } : {},
885
+ ...options.adapterRegistry !== void 0 ? { adapterRegistry: options.adapterRegistry } : {},
886
+ ...options.execFileImpl !== void 0 ? { execFileImpl: options.execFileImpl } : {},
887
+ ...options.createWatcher !== void 0 ? { createWatcher: options.createWatcher } : {},
888
+ ...options.heartbeatIntervalMs !== void 0 ? { heartbeatIntervalMs: options.heartbeatIntervalMs } : {}
889
+ };
890
+ }
891
+ function assertLoopbackAddress(address) {
892
+ if (address.address !== "127.0.0.1") {
893
+ throw new StudioServerStartError(
894
+ "BIND_FAILED",
895
+ address.port,
896
+ "verbatra studio server did not bind to 127.0.0.1"
897
+ );
898
+ }
899
+ }
900
+ function listen(server, port) {
901
+ return new Promise((resolve3, reject) => {
902
+ server.once("error", (error) => {
903
+ if (error.code === "EADDRINUSE") {
904
+ reject(new StudioServerStartError("PORT_IN_USE", port, `port ${port} is already in use`));
905
+ return;
906
+ }
907
+ reject(error);
908
+ });
909
+ server.listen(port, "127.0.0.1", () => {
910
+ try {
911
+ resolve3(resolveBoundAddress(server.address()));
912
+ } catch (error) {
913
+ reject(error);
914
+ }
915
+ });
916
+ });
917
+ }
918
+ function closeServer(server, sseHub, watcher) {
919
+ sseHub.closeAll();
920
+ const serverClosed = new Promise((resolve3, reject) => {
921
+ server.close((error) => error ? reject(error) : resolve3());
922
+ server.closeAllConnections();
923
+ });
924
+ return Promise.all([serverClosed, watcher.close()]).then(() => void 0);
925
+ }
926
+ async function startStudioServer(options) {
927
+ const assetsRootPath = fileURLToPath(options.assetsRoot ?? defaultAssetsRoot());
928
+ const output = options.output ?? defaultOutput;
929
+ const token = options.token ?? generateToken();
930
+ const config = await options.loader();
931
+ const projectRoot = options.cwd ?? process.cwd();
932
+ const watcher = createProjectWatcher(
933
+ { config: config.config, projectRoot },
934
+ { createWatcher: options.createWatcher ?? defaultCreateStudioWatcher }
935
+ );
936
+ const sseHub = createSseHub(
937
+ options.heartbeatIntervalMs !== void 0 ? { heartbeatIntervalMs: options.heartbeatIntervalMs } : {}
938
+ );
939
+ watcher.onRefresh((event) => sseHub.broadcastRefresh(event));
940
+ const server = createServer();
941
+ server.on("clientError", handleClientError);
942
+ const address = await listen(server, resolvePort(options.port));
943
+ assertLoopbackAddress(address);
944
+ const port = address.port;
945
+ const context = {
946
+ port,
947
+ token,
948
+ cookieName: cookieName(port),
949
+ assetsRootPath,
950
+ log: output,
951
+ rpcDeps: buildRpcHandlerDeps(config, projectRoot, options),
952
+ sseHub
953
+ };
954
+ server.on("request", (request, response) => {
955
+ handleRequest(context, request, response).catch(() => {
956
+ request.destroy();
957
+ response.destroy();
958
+ });
959
+ });
960
+ const url = `http://127.0.0.1:${port}/`;
961
+ output(buildBanner(url, token));
962
+ return {
963
+ url,
964
+ port,
965
+ close: () => closeServer(server, sseHub, watcher)
966
+ };
967
+ }
968
+
969
+ export { DEFAULT_STUDIO_PORT, StudioServerStartError, startStudioServer };
970
+ //# sourceMappingURL=index.js.map
971
+ //# sourceMappingURL=index.js.map