@moritzwilksch/diffle 0.1.4 → 0.1.5

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.
Files changed (81) hide show
  1. package/README.md +21 -5
  2. package/dist/client/assets/{angular-html-DCs801AK.js → angular-html-DmrG9JtS.js} +1 -1
  3. package/dist/client/assets/{angular-ts-BWhN0w8_.js → angular-ts-ViWJivwt.js} +1 -1
  4. package/dist/client/assets/{apl-Djz0ErNW.js → apl-C5EQbFuR.js} +1 -1
  5. package/dist/client/assets/{astro-MWC3rLUN.js → astro-Dn81Z0Ad.js} +1 -1
  6. package/dist/client/assets/{blade-B8pycf1w.js → blade-Bw99tw7z.js} +1 -1
  7. package/dist/client/assets/{c-B-Jcxs--.js → c-BUkR06RC.js} +1 -1
  8. package/dist/client/assets/{chapel-CuJCeKLf.js → chapel-BkX4HVjh.js} +1 -1
  9. package/dist/client/assets/{cobol-DgquORVs.js → cobol-DHYNHdYd.js} +1 -1
  10. package/dist/client/assets/{coffee-ECOaIhT5.js → coffee-CwXH1Vh3.js} +1 -1
  11. package/dist/client/assets/{cpp-CqnSivHO.js → cpp-CH4TrKz5.js} +1 -1
  12. package/dist/client/assets/{crystal-Cs7ZYBfN.js → crystal-DKLVzvRx.js} +1 -1
  13. package/dist/client/assets/{css-p0qk9Lci.js → css-Nq8Uu_QZ.js} +1 -1
  14. package/dist/client/assets/{edge-mMULBvUs.js → edge-CJRhOJkj.js} +1 -1
  15. package/dist/client/assets/{elixir-VIosceup.js → elixir-B6z04L_L.js} +1 -1
  16. package/dist/client/assets/{elm-C3kw87lA.js → elm-CylqdfmR.js} +1 -1
  17. package/dist/client/assets/{erb-DZjmloke.js → erb--LFUnTAk.js} +1 -1
  18. package/dist/client/assets/{git-rebase-qAMhScBw.js → git-rebase-CJezoI_U.js} +1 -1
  19. package/dist/client/assets/{glimmer-js-DJdA7CEB.js → glimmer-js-CVs-5jG6.js} +1 -1
  20. package/dist/client/assets/{glimmer-ts-6xtV4JQb.js → glimmer-ts-C8GkuLHj.js} +1 -1
  21. package/dist/client/assets/{glsl-BRqSJ3dN.js → glsl-Cre-e57h.js} +1 -1
  22. package/dist/client/assets/{graphql-nm_X3Hg7.js → graphql-Bflp-kge.js} +1 -1
  23. package/dist/client/assets/{hack-CQ7j-SLC.js → hack-BVXbQcT2.js} +1 -1
  24. package/dist/client/assets/{haml-nw30ZCFe.js → haml-De4W5AvJ.js} +1 -1
  25. package/dist/client/assets/{handlebars-CbbUPaYB.js → handlebars-DrAV6XbB.js} +1 -1
  26. package/dist/client/assets/{html-C-E44L0y.js → html-DWrE5ZtW.js} +1 -1
  27. package/dist/client/assets/{html-derivative-DTCnXXkR.js → html-derivative-4y8Ldk_X.js} +1 -1
  28. package/dist/client/assets/{http-Cg4Jw-kz.js → http-BlF3sH0P.js} +1 -1
  29. package/dist/client/assets/{hurl-CITbwYVL.js → hurl-8Yd7qaL-.js} +1 -1
  30. package/dist/client/assets/{index-BdA8MWLx.js → index-B5j0g-YF.js} +90 -90
  31. package/dist/client/assets/{java-DNT_EPVr.js → java-Da6TVA-D.js} +1 -1
  32. package/dist/client/assets/{javascript-DBrMK690.js → javascript-9H4TWodB.js} +1 -1
  33. package/dist/client/assets/{jinja-DqPnwJo-.js → jinja-CB5h6IWK.js} +1 -1
  34. package/dist/client/assets/{jison-A5ieRkCJ.js → jison-DdpPjkKm.js} +1 -1
  35. package/dist/client/assets/{json-BolWLdCd.js → json-lKrYnKUy.js} +1 -1
  36. package/dist/client/assets/{jsx-CkIysgAJ.js → jsx-Cq_s8bb1.js} +1 -1
  37. package/dist/client/assets/{julia-jBzm267c.js → julia-B2fUm1la.js} +1 -1
  38. package/dist/client/assets/{just-cr8UASbW.js → just-EU9poDOS.js} +1 -1
  39. package/dist/client/assets/{latex-DcdC0vak.js → latex-D94S8ANd.js} +1 -1
  40. package/dist/client/assets/{liquid-BBR6eAOA.js → liquid-Dq3Y6XOW.js} +1 -1
  41. package/dist/client/assets/{lua-CnUVAA9w.js → lua-Dl3Y8EmE.js} +1 -1
  42. package/dist/client/assets/{marko-BxH5DxB_.js → marko-BUvlzVCc.js} +1 -1
  43. package/dist/client/assets/{mdc-CVc0_tcy.js → mdc-5MteepiP.js} +1 -1
  44. package/dist/client/assets/{nginx-B1DQmN_N.js → nginx-DPAEohPR.js} +1 -1
  45. package/dist/client/assets/{nim-C3rsdIhh.js → nim-Dy-zxDpe.js} +1 -1
  46. package/dist/client/assets/{org-DICsbmv1.js → org-a4b5Lf2G.js} +1 -1
  47. package/dist/client/assets/{perl-B-6tgIaz.js → perl-Ba0EQGS8.js} +1 -1
  48. package/dist/client/assets/{php-CSZf1YiM.js → php-BwWPnF9y.js} +1 -1
  49. package/dist/client/assets/{pug-BvQ97HKe.js → pug-DiduM0zv.js} +1 -1
  50. package/dist/client/assets/{qml-B_gSWBeZ.js → qml-Zm_NS-qG.js} +1 -1
  51. package/dist/client/assets/{r-Dbh43Ti8.js → r-Dq0UKcOZ.js} +1 -1
  52. package/dist/client/assets/{razor-Dks3_W39.js → razor-BOvDDN47.js} +1 -1
  53. package/dist/client/assets/{regexp-cZE9On7Z.js → regexp-D3YxDQ6s.js} +1 -1
  54. package/dist/client/assets/{rst-Cgn-1Zks.js → rst-DHbzSb6z.js} +1 -1
  55. package/dist/client/assets/{ruby-DkGM-R-6.js → ruby-CvI-mn5Q.js} +1 -1
  56. package/dist/client/assets/{sas-D6c_PGbn.js → sas-CWQFfwiE.js} +1 -1
  57. package/dist/client/assets/{scss-BKxWJNFh.js → scss-CCpN0AyS.js} +1 -1
  58. package/dist/client/assets/{shellscript-Bcg5lXvK.js → shellscript-BfpoO4g6.js} +1 -1
  59. package/dist/client/assets/{shellsession-Bf400BZo.js → shellsession-agjx723h.js} +1 -1
  60. package/dist/client/assets/{soy-VDZj_scj.js → soy-pEVWulWp.js} +1 -1
  61. package/dist/client/assets/{sql-ushyJdRf.js → sql-DS0UAPd6.js} +1 -1
  62. package/dist/client/assets/{stata-87RYcwm-.js → stata-D8L-Tcxs.js} +1 -1
  63. package/dist/client/assets/{surrealql-DiJkNDgM.js → surrealql-DOkpssDf.js} +1 -1
  64. package/dist/client/assets/{svelte-B6zEQCqX.js → svelte-B4ieXAtz.js} +1 -1
  65. package/dist/client/assets/{templ-BbUkdxgW.js → templ-BdVEAAo4.js} +1 -1
  66. package/dist/client/assets/{tex-8DUlt0ja.js → tex-CYjNIefm.js} +1 -1
  67. package/dist/client/assets/{ts-tags-B9TjJt8W.js → ts-tags-BFD-4qTD.js} +1 -1
  68. package/dist/client/assets/{tsx-KtVv-1tC.js → tsx-CZLDU0bf.js} +1 -1
  69. package/dist/client/assets/{twig-SRZcBkws.js → twig-DYcKmD5x.js} +1 -1
  70. package/dist/client/assets/{typescript-XxBZhs1X.js → typescript-d_JvksIp.js} +1 -1
  71. package/dist/client/assets/{typst-zdrwuFs3.js → typst-B0Uo-CKu.js} +1 -1
  72. package/dist/client/assets/{vue-CIJzu_ou.js → vue-B3Iiop2I.js} +1 -1
  73. package/dist/client/assets/{vue-html-Cbh4Xclf.js → vue-html-B9s0WJG6.js} +1 -1
  74. package/dist/client/assets/{vue-vine-B3rP3Gia.js → vue-vine-CIMF412S.js} +1 -1
  75. package/dist/client/assets/{xml-B8FuGGpH.js → xml-BzXG-icR.js} +1 -1
  76. package/dist/client/assets/{xsl-DGb4ZSQs.js → xsl-Dp1sXze8.js} +1 -1
  77. package/dist/client/assets/{yaml-DgFlGzOM.js → yaml-BW2dXlES.js} +1 -1
  78. package/dist/client/index.html +1 -1
  79. package/dist/server/{Watcher-C_X03LRa.js → Watcher-CKwvGuPB.js} +5 -4
  80. package/dist/server/main.js +291 -120
  81. package/package.json +1 -1
@@ -3951,7 +3951,7 @@ function generateCompletion(program, { shell, executable = program.name() }) {
3951
3951
  }
3952
3952
  //#endregion
3953
3953
  //#region package.json
3954
- var version$1 = "0.1.4";
3954
+ var version$1 = "0.1.5";
3955
3955
  //#endregion
3956
3956
  //#region src/server/comments/anchor.ts
3957
3957
  function splitLines(contents) {
@@ -3996,10 +3996,15 @@ function quoteRange(contents, startLine, endLine) {
3996
3996
  if (startLine < 1 || endLine < startLine || endLine > lines.length) return null;
3997
3997
  return lines.slice(startLine - 1, endLine).join("\n");
3998
3998
  }
3999
- /** Review order: by path, then line, then when the thread was opened. */
3999
+ /** The line a thread sorts under: a file thread comes before every line of its file. */
4000
+ function anchorLine(anchor) {
4001
+ return anchor.kind === "line" ? anchor.startLine : 0;
4002
+ }
4003
+ /** Review order: by path, then line (file threads first), then when the thread was opened. */
4000
4004
  function compareThreads(a, b) {
4001
4005
  if (a.anchor.path !== b.anchor.path) return a.anchor.path < b.anchor.path ? -1 : 1;
4002
- if (a.anchor.startLine !== b.anchor.startLine) return a.anchor.startLine - b.anchor.startLine;
4006
+ const line = anchorLine(a.anchor) - anchorLine(b.anchor);
4007
+ if (line !== 0) return line;
4003
4008
  return (a.messages[0]?.createdAt ?? 0) - (b.messages[0]?.createdAt ?? 0);
4004
4009
  }
4005
4010
  //#endregion
@@ -4007,21 +4012,23 @@ function compareThreads(a, b) {
4007
4012
  /**
4008
4013
  * Agent-prompt format, one block per thread:
4009
4014
  *
4010
- * path:line (or path:start-end)
4015
+ * path:line (or path:start-end; `(file) path` for a thread on the whole file)
4011
4016
  *
4012
- * > quoted line
4017
+ * > quoted line (absent for a file thread)
4013
4018
  *
4014
4019
  * body (a thread's messages joined by a blank line)
4015
4020
  *
4016
4021
  * ---
4017
4022
  *
4018
- * A ```suggestion fence in a body expands to ORIGINAL / SUGGESTED fences.
4023
+ * A ```suggestion fence in a body expands to ORIGINAL / SUGGESTED fences; a file
4024
+ * thread has no original, so its fences stay as written.
4019
4025
  */
4020
4026
  function formatPrompt(threads) {
4021
4027
  return [...threads].sort(compareThreads).map(formatOne).join("\n");
4022
4028
  }
4023
4029
  function formatOne(t) {
4024
4030
  const { anchor } = t;
4031
+ if (anchor.kind === "file") return `${`(file) ${t.stale ? "(stale) " : ""}${anchor.path}`}\n\n${t.messages.map((m) => m.body.trim()).join("\n\n")}\n\n---\n`;
4025
4032
  const range = anchor.startLine === anchor.endLine ? `${anchor.startLine}` : `${anchor.startLine}-${anchor.endLine}`;
4026
4033
  const prefixes = [];
4027
4034
  if (anchor.side === "old") prefixes.push("(removed)");
@@ -4720,6 +4727,31 @@ var GitRepo = class GitRepo {
4720
4727
  await fh?.close();
4721
4728
  }
4722
4729
  }
4730
+ /**
4731
+ * Boolean value of a gitattribute per path: `attr` or `attr=true` reads
4732
+ * true, `-attr` or `attr=false` false; unspecified paths are absent. A
4733
+ * revision reads `.gitattributes` from that tree, the worktree from the
4734
+ * checkout. `--source` needs git 2.40.
4735
+ */
4736
+ async attr(name, paths, rev) {
4737
+ const out = /* @__PURE__ */ new Map();
4738
+ if (paths.length === 0) return out;
4739
+ const source = rev === "worktree" ? [] : [`--source=${rev}`];
4740
+ const fields = splitZ(await this.exec([
4741
+ "check-attr",
4742
+ "-z",
4743
+ "--stdin",
4744
+ ...source,
4745
+ name,
4746
+ "--"
4747
+ ], { input: paths.join("\0") }));
4748
+ for (let i = 0; i + 2 < fields.length; i += 3) {
4749
+ const value = fields[i + 2];
4750
+ if (value === "set" || value === "true") out.set(fields[i], true);
4751
+ else if (value === "unset" || value === "false") out.set(fields[i], false);
4752
+ }
4753
+ return out;
4754
+ }
4723
4755
  /** Paths git ignores, from a candidate list. */
4724
4756
  async ignored(paths) {
4725
4757
  if (paths.length === 0) return /* @__PURE__ */ new Set();
@@ -5076,11 +5108,15 @@ var GithubError = class extends Error {
5076
5108
  this.status = status;
5077
5109
  }
5078
5110
  };
5111
+ function isFileComment(c) {
5112
+ return "subject_type" in c;
5113
+ }
5079
5114
  /**
5080
5115
  * Threads → the comments of one pending GitHub review, one per thread. Stale threads are skipped:
5081
5116
  * their lines no longer sit in the diff, and GitHub would refuse them anyway. With
5082
5117
  * `threadIds`, only those (resolved included, the user asked for them by hand);
5083
- * without, every unresolved thread.
5118
+ * without, every unresolved thread. `review.comments` holds the line comments REST can
5119
+ * create; the file comments are appended afterwards, but `ids` counts both, in review order.
5084
5120
  */
5085
5121
  function buildReview(threads, commitId, threadIds) {
5086
5122
  const skipped = [];
@@ -5113,20 +5149,27 @@ function buildReview(threads, commitId, threadIds) {
5113
5149
  return {
5114
5150
  review: {
5115
5151
  commit_id: commitId,
5116
- comments
5152
+ comments: comments.filter((c) => !isFileComment(c))
5117
5153
  },
5154
+ comments,
5118
5155
  ids,
5119
5156
  skipped
5120
5157
  };
5121
5158
  }
5122
5159
  function toReviewComment(t) {
5123
5160
  const { anchor } = t;
5161
+ const body = formatBody(t.messages);
5162
+ if (anchor.kind === "file") return {
5163
+ path: anchor.path,
5164
+ subject_type: "file",
5165
+ body
5166
+ };
5124
5167
  const side = anchor.side === "old" ? "LEFT" : "RIGHT";
5125
5168
  const c = {
5126
5169
  path: anchor.path,
5127
5170
  line: anchor.endLine,
5128
5171
  side,
5129
- body: formatBody(t.messages)
5172
+ body
5130
5173
  };
5131
5174
  if (anchor.startLine !== anchor.endLine) {
5132
5175
  c.start_line = anchor.startLine;
@@ -5171,15 +5214,25 @@ var GithubExporter = class {
5171
5214
  */
5172
5215
  async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run = runGh }) {
5173
5216
  if (snap.newSha === "worktree") throw new GithubError("GitHub cannot anchor comments to the worktree");
5174
- const { review, ids, skipped } = buildReview(threads, snap.newSha, threadIds);
5175
- if (review.comments.length === 0) throw new GithubError(skipped.length ? `nothing to post: ${describe(skipped)}` : "nothing to post", 400);
5176
- for (const [i, comment] of review.comments.entries()) comment.body += `\n\n${threadMarker(ids[i])}`;
5217
+ const { review, comments, ids, skipped } = buildReview(threads, snap.newSha, threadIds);
5218
+ if (comments.length === 0) throw new GithubError(skipped.length ? `nothing to post: ${describe(skipped)}` : "nothing to post", 400);
5219
+ for (const [i, comment] of comments.entries()) comment.body += `\n\n${threadMarker(ids[i])}`;
5177
5220
  const pending = await findPendingReview(run, snap.root, pr);
5178
5221
  if (!pending) {
5179
- await createPendingReview(run, snap.root, pr.repository, pr.number, review);
5222
+ const created = await createPendingReview(run, snap.root, pr.repository, pr.number, review);
5223
+ let appended = 0;
5224
+ try {
5225
+ for (const c of comments.filter(isFileComment)) {
5226
+ await addToPendingReview(run, snap.root, created.id, c);
5227
+ appended++;
5228
+ }
5229
+ } catch (e) {
5230
+ const detail = e instanceof Error ? e.message : String(e);
5231
+ throw new GithubError(`created the pending review with ${review.comments.length + appended} comments, then ${detail}`, 502);
5232
+ }
5180
5233
  return {
5181
5234
  url: pr.url,
5182
- posted: review.comments.length,
5235
+ posted: comments.length,
5183
5236
  updated: 0,
5184
5237
  review: "created",
5185
5238
  skipped
@@ -5190,7 +5243,7 @@ async function exportToGithub({ snap, pullRequest: pr, threads, threadIds, run =
5190
5243
  let updated = 0;
5191
5244
  let posted = 0;
5192
5245
  try {
5193
- for (const [i, c] of review.comments.entries()) {
5246
+ for (const [i, c] of comments.entries()) {
5194
5247
  const id = ids[i];
5195
5248
  const at = existing.get(anchorKey(c));
5196
5249
  const index = at?.findIndex((comment) => comment.body.trimEnd().endsWith(threadMarker(id))) ?? -1;
@@ -5234,6 +5287,7 @@ function threadMarker(id) {
5234
5287
  }
5235
5288
  /** The anchor must still match before we rewrite an exported thread. */
5236
5289
  function anchorKey(c) {
5290
+ if (isFileComment(c)) return [c.path, "FILE"].join("\0");
5237
5291
  return [
5238
5292
  c.path,
5239
5293
  c.side,
@@ -5264,7 +5318,7 @@ async function findPendingReview(run, cwd, pr) {
5264
5318
  const THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!,$after:String){
5265
5319
  repository(owner:$owner,name:$repo){pullRequest(number:$number){reviewThreads(first:100,after:$after){
5266
5320
  pageInfo{hasNextPage endCursor}
5267
- nodes{path line startLine diffSide comments(first:1){nodes{id body pullRequestReview{id}}}}
5321
+ nodes{path line startLine diffSide subjectType comments(first:1){nodes{id body pullRequestReview{id}}}}
5268
5322
  }}}
5269
5323
  }`;
5270
5324
  /**
@@ -5288,15 +5342,8 @@ async function pendingComments(run, cwd, pr, reviewId) {
5288
5342
  for (const t of threads.nodes) {
5289
5343
  const c = t?.comments?.nodes?.[0];
5290
5344
  if (!t?.path || !c?.id || c.pullRequestReview?.id !== reviewId) continue;
5291
- const line = t.line;
5292
- if (line == null) continue;
5293
- const side = t.diffSide === "LEFT" ? "LEFT" : "RIGHT";
5294
- const key = anchorKey({
5295
- path: t.path,
5296
- side,
5297
- line,
5298
- start_line: t.startLine ?? void 0
5299
- });
5345
+ const key = pendingKey(t);
5346
+ if (key == null) continue;
5300
5347
  const at = by.get(key);
5301
5348
  if (at) at.push({
5302
5349
  nodeId: c.id,
@@ -5313,10 +5360,30 @@ async function pendingComments(run, cwd, pr, reviewId) {
5313
5360
  }
5314
5361
  throw new GithubError("pending review exceeds the 1000-thread lookup limit; export stopped without changes", 502);
5315
5362
  }
5363
+ /** The anchor key of a thread already in the review, or null for one GitHub reports without a position. */
5364
+ function pendingKey(t) {
5365
+ const path = t.path;
5366
+ if (t.subjectType === "FILE") return anchorKey({
5367
+ path,
5368
+ subject_type: "file",
5369
+ body: ""
5370
+ });
5371
+ if (t.line == null) return null;
5372
+ return anchorKey({
5373
+ path,
5374
+ side: t.diffSide === "LEFT" ? "LEFT" : "RIGHT",
5375
+ line: t.line,
5376
+ start_line: t.startLine ?? void 0,
5377
+ body: ""
5378
+ });
5379
+ }
5316
5380
  const UPDATE_COMMENT = `mutation($input:UpdatePullRequestReviewCommentInput!){updatePullRequestReviewComment(input:$input){pullRequestReviewComment{id}}}`;
5317
- /** No `event` in the body, so GitHub keeps the new review pending with all of its comments. */
5381
+ /**
5382
+ * No `event` in the body, so GitHub keeps the new review pending with all of its comments.
5383
+ * Resolves the review's node id, for the comments REST cannot carry.
5384
+ */
5318
5385
  async function createPendingReview(run, cwd, repository, number, review) {
5319
- await run([
5386
+ const out = await run([
5320
5387
  "api",
5321
5388
  "--method",
5322
5389
  "POST",
@@ -5327,20 +5394,31 @@ async function createPendingReview(run, cwd, repository, number, review) {
5327
5394
  cwd,
5328
5395
  input: JSON.stringify(review)
5329
5396
  });
5397
+ let id;
5398
+ try {
5399
+ id = JSON.parse(out).node_id;
5400
+ } catch {
5401
+ throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
5402
+ }
5403
+ if (typeof id !== "string") throw new GithubError(`unexpected output from gh api: ${out.slice(0, 200)}`, 502);
5404
+ return { id };
5330
5405
  }
5331
5406
  const ADD_THREAD = `mutation($input:AddPullRequestReviewThreadInput!){addPullRequestReviewThread(input:$input){thread{id}}}`;
5332
- /** Appends one thread through GraphQL; REST cannot extend a pending review. */
5407
+ /** Appends one thread through GraphQL; REST cannot extend a pending review, nor place a file comment. */
5333
5408
  async function addToPendingReview(run, cwd, reviewId, c) {
5334
5409
  const input = {
5335
5410
  pullRequestReviewId: reviewId,
5336
5411
  path: c.path,
5337
- line: c.line,
5338
- side: c.side,
5339
5412
  body: c.body
5340
5413
  };
5341
- if (c.start_line != null) {
5342
- input.startLine = c.start_line;
5343
- input.startSide = c.start_side;
5414
+ if (isFileComment(c)) input.subjectType = "FILE";
5415
+ else {
5416
+ input.line = c.line;
5417
+ input.side = c.side;
5418
+ if (c.start_line != null) {
5419
+ input.startLine = c.start_line;
5420
+ input.startSide = c.start_side;
5421
+ }
5344
5422
  }
5345
5423
  await graphql(run, cwd, ADD_THREAD, { input });
5346
5424
  }
@@ -11164,7 +11242,7 @@ const ChangedFileSchema = object({
11164
11242
  binary: boolean(),
11165
11243
  /** New-side blob sha. '' for deletions. Keys the viewed state. */
11166
11244
  blob: string(),
11167
- /** A path pattern or a content sniff said this file is generated. Feeds auto-collapse and risk ranking. */
11245
+ /** The `linguist-generated` gitattribute, a path pattern or a content sniff said this file is generated. Feeds auto-collapse and risk ranking. */
11168
11246
  generated: boolean(),
11169
11247
  /** A gitlink (mode 160000): `blob` is the recorded commit, the patch shows the commit-id change, and there is no file to open. */
11170
11248
  submodule: literal(true).optional()
@@ -11192,7 +11270,8 @@ object({
11192
11270
  contents: string(),
11193
11271
  binary: boolean()
11194
11272
  });
11195
- const CommentAnchorSchema = object({
11273
+ const CommentAnchorSchema = discriminatedUnion("kind", [object({
11274
+ kind: literal("line"),
11196
11275
  path: string(),
11197
11276
  /** Column the user selected in a diff; 'new' for file items. */
11198
11277
  side: SideSchema,
@@ -11200,7 +11279,11 @@ const CommentAnchorSchema = object({
11200
11279
  endLine: number(),
11201
11280
  /** Exact text of [startLine, endLine] at creation. */
11202
11281
  quoted: string()
11203
- });
11282
+ }), object({
11283
+ kind: literal("file"),
11284
+ /** The changed file's path (its new path for a rename), or any tree path. */
11285
+ path: string()
11286
+ })]);
11204
11287
  const CommentMessageSchema = object({
11205
11288
  id: string(),
11206
11289
  body: string(),
@@ -11214,24 +11297,31 @@ object({
11214
11297
  messages: CommentMessageSchema.array().min(1),
11215
11298
  resolved: boolean(),
11216
11299
  resolvedAt: number().optional(),
11217
- /** Relocation failed after a snapshot refresh. */
11300
+ /** Relocation failed after a snapshot refresh: the lines left the diff, or the file left the review. */
11218
11301
  stale: boolean(),
11219
- /** Original startLine, shown in export when stale. */
11302
+ /** Original startLine of a line thread, shown in export when stale. */
11220
11303
  staleFromLine: number().optional()
11221
11304
  });
11222
- /** Create payload. `quoted` is optional: the server quotes the range from the snapshot. */
11305
+ /**
11306
+ * Create payload. Without `startLine` the thread is on the file as a whole; then `side`,
11307
+ * `endLine` and `quoted` are refused rather than ignored, so a payload that meant a line
11308
+ * never lands on the file. `quoted` is optional: the server quotes the range from the snapshot.
11309
+ */
11223
11310
  const ThreadCreateSchema = object({
11224
11311
  path: string().min(1),
11225
11312
  /** Default 'new'. */
11226
11313
  side: SideSchema.optional(),
11227
- startLine: number().int().positive(),
11314
+ startLine: number().int().positive().optional(),
11228
11315
  /** Default startLine. */
11229
11316
  endLine: number().int().positive().optional(),
11230
11317
  body: string().refine((body) => body.trim().length > 0, "body required"),
11231
11318
  quoted: string().optional()
11232
- }).refine((t) => t.endLine == null || t.endLine >= t.startLine, {
11319
+ }).refine((t) => t.startLine == null || t.endLine == null || t.endLine >= t.startLine, {
11233
11320
  path: ["endLine"],
11234
11321
  message: "endLine must be ≥ startLine"
11322
+ }).refine((t) => t.startLine != null || t.side == null && t.endLine == null && t.quoted == null, {
11323
+ path: ["startLine"],
11324
+ message: "side, endLine and quoted need a startLine; leave all four out for a comment on the whole file"
11235
11325
  });
11236
11326
  const ReplyCreateSchema = object({ body: string().refine((body) => body.trim().length > 0, "body required") });
11237
11327
  const ThreadStateSchema = _enum([
@@ -15780,6 +15870,7 @@ const LOOPBACK = /* @__PURE__ */ new Set([
15780
15870
  "::1",
15781
15871
  "localhost"
15782
15872
  ]);
15873
+ const HINT = "if a reverse proxy sent it, trust that origin with --allowed-origin";
15783
15874
  /**
15784
15875
  * Restricts API and WebSocket requests to local hosts and the configured public origin.
15785
15876
  * Proxies may preserve the public Host or rewrite it to an allowed upstream Host.
@@ -15794,20 +15885,24 @@ function requestGuard(bindHost, names = ownNames(), allowedOrigin) {
15794
15885
  ]);
15795
15886
  const publicHost = allowedOrigin == null ? void 0 : new URL(allowedOrigin).host;
15796
15887
  return ({ host, origin }) => {
15797
- if (!host) return false;
15888
+ if (!host) return "missing Host header";
15798
15889
  const name = hostnameOf(host).toLowerCase();
15799
15890
  const localHost = loopback ? LOOPBACK.has(name) : isIP(name) !== 0 || allowed.has(name);
15800
15891
  const proxyHost = publicHost != null && host.toLowerCase() === publicHost;
15801
- if (!localHost && !proxyHost) return false;
15802
- if (origin == null || allowedOrigin != null && origin === allowedOrigin) return true;
15803
- if (proxyHost) return false;
15804
- try {
15805
- return new URL(origin).host === host;
15806
- } catch {
15807
- return false;
15808
- }
15892
+ if (!localHost && !proxyHost) return `forbidden Host "${host}"; ${HINT}`;
15893
+ if (origin == null || allowedOrigin != null && origin === allowedOrigin) return null;
15894
+ if (!proxyHost && parseHost(origin) === host) return null;
15895
+ return `forbidden Origin "${origin}" for Host "${host}"; ${HINT}`;
15809
15896
  };
15810
15897
  }
15898
+ /** The host of a syntactically valid origin; undefined for `null` and other non-URLs. */
15899
+ function parseHost(origin) {
15900
+ try {
15901
+ return new URL(origin).host;
15902
+ } catch {
15903
+ return;
15904
+ }
15905
+ }
15811
15906
  /** The machine's hostname, its short form, and its mDNS name, so LAN visitors can use any of them. */
15812
15907
  function ownNames() {
15813
15908
  const full = hostname();
@@ -15877,15 +15972,17 @@ function toThreadCreate(entry, where) {
15877
15972
  }
15878
15973
  /** True when `t` duplicates an open thread at the same anchor whose opening message has the same body. */
15879
15974
  function isDuplicate(existing, t) {
15975
+ const body = t.body.trim();
15976
+ const open = existing.filter((e) => !e.resolved && e.anchor.path === t.path && e.messages[0]?.body.trim() === body);
15977
+ if (t.startLine == null) return open.some((e) => e.anchor.kind === "file");
15880
15978
  const side = t.side ?? "new";
15881
15979
  const endLine = t.endLine ?? t.startLine;
15882
- const body = t.body.trim();
15883
- return existing.some((e) => !e.resolved && e.anchor.path === t.path && e.anchor.side === side && e.anchor.startLine === t.startLine && e.anchor.endLine === endLine && e.messages[0]?.body.trim() === body);
15980
+ return open.some((e) => e.anchor.kind === "line" && e.anchor.side === side && e.anchor.startLine === t.startLine && e.anchor.endLine === endLine);
15884
15981
  }
15885
15982
  //#endregion
15886
15983
  //#region src/server/comments/CommentStore.ts
15887
15984
  var NotFoundError = class extends Error {};
15888
- /** A range the snapshot cannot quote: path absent on that side, or lines past the end. */
15985
+ /** An anchor the snapshot cannot place: path absent on that side, or lines past the end. */
15889
15986
  var UnquotableError = class extends Error {};
15890
15987
  /**
15891
15988
  * Owns this mode's set in `<gitDir>/diffle/comments.json`: threads and viewed
@@ -15902,14 +15999,14 @@ var CommentStore = class CommentStore {
15902
15999
  this.file = file;
15903
16000
  this.key = key;
15904
16001
  }
15905
- /** Opens the store; a v1 file is backed up to `comments.v1.bak` and rewritten as v2 first. */
16002
+ /** Opens the store; an older file is backed up to `comments.v<n>.bak` and rewritten in the current format first. */
15906
16003
  static async open(gitDir, modeKey) {
15907
16004
  const file = join(gitDir, "diffle", "comments.json");
15908
16005
  await withFileLock(file, async () => {
15909
16006
  const all = await readStoreFile(file);
15910
- if (all.version !== 1) return;
15911
- await copyFile(file, join(gitDir, "diffle", "comments.v1.bak"));
15912
- await writeStoreFile(file, migrateV1(all));
16007
+ if (all.version === 3) return;
16008
+ await copyFile(file, join(gitDir, "diffle", `comments.v${all.version}.bak`));
16009
+ await writeStoreFile(file, upgrade(all));
15913
16010
  });
15914
16011
  return new CommentStore(file, modeKey);
15915
16012
  }
@@ -15985,12 +16082,13 @@ var CommentStore = class CommentStore {
15985
16082
  });
15986
16083
  }
15987
16084
  /**
15988
- * Adds each import that is not already an open duplicate. Every range is
15989
- * checked against the snapshot through `quote`; a payload's own `quoted` (the
15990
- * text the browser showed) is kept once the range is known to exist. Throws
15991
- * UnquotableError, adding nothing, when any range cannot be read.
16085
+ * Adds each import that is not already an open duplicate. Every anchor is
16086
+ * checked against the snapshot through `source`: a range is quoted (a payload's
16087
+ * own `quoted`, the text the browser showed, is kept once the range is known to
16088
+ * exist) and a file thread's path must be in the review. Throws UnquotableError,
16089
+ * adding nothing, when any anchor cannot be placed.
15992
16090
  */
15993
- importThreads(imports, quote) {
16091
+ importThreads(imports, source) {
15994
16092
  return this.mutate(async (set) => {
15995
16093
  const added = [];
15996
16094
  const pending = [];
@@ -16001,18 +16099,7 @@ var CommentStore = class CommentStore {
16001
16099
  skipped++;
16002
16100
  continue;
16003
16101
  }
16004
- const side = t.side ?? "new";
16005
- const startLine = t.startLine;
16006
- const endLine = t.endLine ?? startLine;
16007
- const fromSnapshot = await quote(t.path, side, startLine, endLine);
16008
- if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
16009
- const anchor = {
16010
- path: t.path,
16011
- side,
16012
- startLine,
16013
- endLine,
16014
- quoted: t.quoted ?? fromSnapshot
16015
- };
16102
+ const anchor = await placeImport(t, source);
16016
16103
  pending.push({
16017
16104
  anchor,
16018
16105
  t
@@ -16093,17 +16180,18 @@ var CommentStore = class CommentStore {
16093
16180
  });
16094
16181
  }
16095
16182
  /**
16096
- * Re-anchor every thread against the current review. A thread is stale when
16097
- * its side is gone, its text is not found, or the relocated range falls
16098
- * outside what the review shows. Returns true if anything changed.
16183
+ * Re-anchor every thread against the current review. A line thread is stale
16184
+ * when its side is gone, its text is not found, or the relocated range falls
16185
+ * outside what the review shows; a file thread when its file left the review.
16186
+ * Returns true if anything changed.
16099
16187
  */
16100
- async relocateAll(view) {
16188
+ async relocateAll(review) {
16101
16189
  const cache = /* @__PURE__ */ new Map();
16102
16190
  const load = (path, side) => {
16103
16191
  const k = `${side}:${path}`;
16104
16192
  let p = cache.get(k);
16105
16193
  if (!p) {
16106
- p = view(path, side);
16194
+ p = review.side(path, side);
16107
16195
  cache.set(k, p);
16108
16196
  }
16109
16197
  return p;
@@ -16111,6 +16199,14 @@ var CommentStore = class CommentStore {
16111
16199
  return this.mutate(async (set) => {
16112
16200
  let changed = false;
16113
16201
  for (const t of set.threads) {
16202
+ if (t.anchor.kind === "file") {
16203
+ const stale = !review.hasFile(t.anchor.path);
16204
+ if (stale !== t.stale) {
16205
+ t.stale = stale;
16206
+ changed = true;
16207
+ }
16208
+ continue;
16209
+ }
16114
16210
  const v = await load(t.anchor.path, t.anchor.side);
16115
16211
  let moved = v == null ? null : relocate(t.anchor, v.contents);
16116
16212
  if (moved && v?.shown && !isShown(v.shown, moved.startLine, moved.endLine)) moved = null;
@@ -16147,7 +16243,7 @@ var CommentStore = class CommentStore {
16147
16243
  viewed: []
16148
16244
  };
16149
16245
  }
16150
- return ownSet(parsed.version === 2 ? parsed : migrateV1(parsed), this.key);
16246
+ return ownSet(upgrade(parsed), this.key);
16151
16247
  }
16152
16248
  /**
16153
16249
  * Locked read-modify-write: `fn` edits this key's set as read from disk under
@@ -16157,12 +16253,11 @@ var CommentStore = class CommentStore {
16157
16253
  */
16158
16254
  mutate(fn, changed = () => true) {
16159
16255
  return withFileLock(this.file, async () => {
16160
- const onDisk = await readStoreFile(this.file);
16161
- const all = onDisk.version === 2 ? onDisk : migrateV1(onDisk);
16256
+ const all = upgrade(await readStoreFile(this.file));
16162
16257
  const set = ownSet(all, this.key);
16163
16258
  const r = await fn(set);
16164
16259
  if (changed(r)) await writeStoreFile(this.file, {
16165
- version: 2,
16260
+ version: 3,
16166
16261
  sets: {
16167
16262
  ...all.sets,
16168
16263
  [this.key]: set
@@ -16184,7 +16279,33 @@ function findThread(set, id) {
16184
16279
  if (!t) throw new NotFoundError(id);
16185
16280
  return t;
16186
16281
  }
16187
- /** The file as stored, or an empty v2 store when absent. Malformed JSON propagates. */
16282
+ /**
16283
+ * The anchor an import lands on, checked against the snapshot. Throws
16284
+ * UnquotableError for a range that cannot be quoted or a file the review lacks.
16285
+ */
16286
+ async function placeImport(t, source) {
16287
+ if (t.startLine == null) {
16288
+ if (!await source.hasFile(t.path)) throw new UnquotableError(`${t.path} is not in the review`);
16289
+ return {
16290
+ kind: "file",
16291
+ path: t.path
16292
+ };
16293
+ }
16294
+ const side = t.side ?? "new";
16295
+ const startLine = t.startLine;
16296
+ const endLine = t.endLine ?? startLine;
16297
+ const fromSnapshot = await source.quote(t.path, side, startLine, endLine);
16298
+ if (fromSnapshot == null) throw new UnquotableError(`cannot quote ${t.path}:${startLine}${endLine !== startLine ? `-${endLine}` : ""} on the ${side} side`);
16299
+ return {
16300
+ kind: "line",
16301
+ path: t.path,
16302
+ side,
16303
+ startLine,
16304
+ endLine,
16305
+ quoted: t.quoted ?? fromSnapshot
16306
+ };
16307
+ }
16308
+ /** The file as stored, or an empty store when absent. Malformed JSON propagates. */
16188
16309
  async function readStoreFile(file) {
16189
16310
  try {
16190
16311
  return parseStoreFile(await readFile(file, "utf8"));
@@ -16192,20 +16313,26 @@ async function readStoreFile(file) {
16192
16313
  if (e.code !== "ENOENT") throw e;
16193
16314
  }
16194
16315
  return {
16195
- version: 2,
16316
+ version: 3,
16196
16317
  sets: {}
16197
16318
  };
16198
16319
  }
16199
- /** Unknown shapes read as an empty v2 store. */
16320
+ /** Unknown shapes read as an empty store. */
16200
16321
  function parseStoreFile(text) {
16201
16322
  const parsed = JSON.parse(text);
16323
+ if (parsed.version === 3 && parsed.sets) return parsed;
16202
16324
  if (parsed.version === 2 && parsed.sets) return parsed;
16203
16325
  if (parsed.version === 1 && parsed.sets) return parsed;
16204
16326
  return {
16205
- version: 2,
16327
+ version: 3,
16206
16328
  sets: {}
16207
16329
  };
16208
16330
  }
16331
+ /** Any stored format as the current one. */
16332
+ function upgrade(all) {
16333
+ if (all.version === 3) return all;
16334
+ return migrateV2(all.version === 2 ? all : migrateV1(all));
16335
+ }
16209
16336
  /** Atomic write. Quoted source lines: owner-only. */
16210
16337
  function writeStoreFile(file, data) {
16211
16338
  return writeFileAtomic(file, JSON.stringify(data, null, 2), 384);
@@ -16219,6 +16346,24 @@ function newMessage(msg) {
16219
16346
  updatedAt: now
16220
16347
  };
16221
16348
  }
16349
+ /** Every v2 anchor is a line range; v3 says so. */
16350
+ function migrateV2(v2) {
16351
+ const sets = {};
16352
+ for (const [key, set] of Object.entries(v2.sets)) sets[key] = {
16353
+ threads: (set.threads ?? []).map((t) => ({
16354
+ ...t,
16355
+ anchor: {
16356
+ kind: "line",
16357
+ ...t.anchor
16358
+ }
16359
+ })),
16360
+ viewed: set.viewed ?? []
16361
+ };
16362
+ return {
16363
+ version: 3,
16364
+ sets
16365
+ };
16366
+ }
16222
16367
  /** Each v1 comment becomes a single-message, unresolved thread. */
16223
16368
  function migrateV1(v1) {
16224
16369
  const sets = {};
@@ -16349,7 +16494,7 @@ function createApi(deps) {
16349
16494
  });
16350
16495
  app.post("/api/threads", async (c) => {
16351
16496
  const imports = parseImports(await readJson(c));
16352
- const { added } = await session.comments.importThreads(imports, session.quoter());
16497
+ const { added } = await session.comments.importThreads(imports, session.anchorSource());
16353
16498
  if (added.length) hub.broadcast({ type: "threads" });
16354
16499
  return c.json(added, 201);
16355
16500
  });
@@ -16499,10 +16644,11 @@ var Server = class {
16499
16644
  const app = new Hono();
16500
16645
  const guard = requestGuard(this.opts.host, void 0, this.opts.allowedOrigin);
16501
16646
  app.use("/api/*", async (c, next) => {
16502
- if (!guard({
16647
+ const rejected = guard({
16503
16648
  host: c.req.header("host"),
16504
16649
  origin: c.req.header("origin")
16505
- })) return c.json({ error: "forbidden origin" }, 403);
16650
+ });
16651
+ if (rejected != null) return c.json({ error: rejected }, 403);
16506
16652
  await next();
16507
16653
  });
16508
16654
  app.use("/api/*", async (c, next) => {
@@ -16892,6 +17038,8 @@ function looksGenerated(path, head) {
16892
17038
  //#endregion
16893
17039
  //#region src/server/Snapshotter.ts
16894
17040
  const PREWARM = 3;
17041
+ /** The gitattribute GitHub reads to collapse generated files; `=false` opts a file out of the heuristics. */
17042
+ const GENERATED_ATTR = "linguist-generated";
16895
17043
  /** Concurrent worktree reads while flagging generated files. */
16896
17044
  const SNIFF_CONCURRENCY = 8;
16897
17045
  /** Sniff verdicts remembered by blob sha; the map is emptied, not evicted, past this. */
@@ -17000,14 +17148,23 @@ var Snapshotter = class {
17000
17148
  return snap;
17001
17149
  }
17002
17150
  /**
17003
- * Sets `generated` on each non-binary changed file: by path first, then by a
17004
- * SNIFF_BYTES content sniff. Blobs already classified are not read again;
17005
- * commit-side blobs go through one `cat-file --batch`, worktree files through
17006
- * a bounded read each. Never fails the snapshot: a read error means false.
17151
+ * Sets `generated` on each non-binary changed file. A `linguist-generated`
17152
+ * gitattribute on the new side decides outright, as on GitHub; otherwise a
17153
+ * path convention, then a SNIFF_BYTES content sniff. Blobs already sniffed
17154
+ * are not read again; commit-side blobs go through one `cat-file --batch`,
17155
+ * worktree files through a bounded read each. Never fails the snapshot: a
17156
+ * failed attribute lookup or read means the next heuristic, then false.
17007
17157
  * Submodules have no contents to sniff.
17008
17158
  */
17009
17159
  async fillGenerated(files, newSha) {
17010
- const sniff = files.filter((f) => f.status !== "D" && !f.binary && !f.submodule).filter((f) => !(f.generated = looksGenerated(f.path, null)));
17160
+ const textual = files.filter((f) => f.status !== "D" && !f.binary && !f.submodule);
17161
+ const marked = await this.repo.attr(GENERATED_ATTR, textual.map((f) => f.path), newSha).catch(() => /* @__PURE__ */ new Map());
17162
+ const sniff = [];
17163
+ for (const f of textual) {
17164
+ const attr = marked.get(f.path);
17165
+ if (attr != null) f.generated = attr;
17166
+ else if (!(f.generated = looksGenerated(f.path, null))) sniff.push(f);
17167
+ }
17011
17168
  const unknown = [];
17012
17169
  for (const f of sniff) {
17013
17170
  const known = f.blob ? this.sniffed.get(f.blob) : void 0;
@@ -17232,21 +17389,24 @@ var Session = class {
17232
17389
  /**
17233
17390
  * Re-anchors threads against what `snap` shows. A changed file shows its
17234
17391
  * hunks; an unchanged tree file shows its new side whole (the file view) and
17235
- * nothing of its old side.
17392
+ * nothing of its old side. A file thread only needs its file in the review.
17236
17393
  */
17237
17394
  async relocateComments(a, snap) {
17238
- await a.comments.relocateAll(async (path, side) => {
17239
- const buf = await this.readSide(snap, path, side);
17240
- if (buf == null) return null;
17241
- const contents = buf.toString("utf8");
17242
- if (!snap.changed.some((f) => f.path === path)) return side === "new" ? {
17243
- contents,
17244
- shown: null
17245
- } : null;
17246
- return {
17247
- contents,
17248
- shown: shownRanges(await a.snapshotter.patch(path) ?? "", side)
17249
- };
17395
+ await a.comments.relocateAll({
17396
+ side: async (path, side) => {
17397
+ const buf = await this.readSide(snap, path, side);
17398
+ if (buf == null) return null;
17399
+ const contents = buf.toString("utf8");
17400
+ if (!snap.changed.some((f) => f.path === path)) return side === "new" ? {
17401
+ contents,
17402
+ shown: null
17403
+ } : null;
17404
+ return {
17405
+ contents,
17406
+ shown: shownRanges(await a.snapshotter.patch(path) ?? "", side)
17407
+ };
17408
+ },
17409
+ hasFile: (path) => this.hasFile(snap, path)
17250
17410
  });
17251
17411
  }
17252
17412
  /**
@@ -17265,15 +17425,24 @@ var Session = class {
17265
17425
  hasSide(snap, path, side) {
17266
17426
  return sidePath(this.readablePaths(snap), path, side) != null;
17267
17427
  }
17428
+ /** Whether `path` is a file of the review: a changed file (by its new path) or a tree path. A rename's old path is not. */
17429
+ hasFile(snap, path) {
17430
+ const r = this.readablePaths(snap);
17431
+ return r.tree.has(path) || r.changed.has(path);
17432
+ }
17268
17433
  /**
17269
- * Quotes a line range from the current snapshot, for imports that carry no
17270
- * `quoted`. Goes through `readSide`, so the allowlist applies.
17434
+ * Places imported anchors against the current snapshot: quotes a line range for
17435
+ * imports that carry no `quoted`, and says whether a file thread's file is in the
17436
+ * review. Goes through `readSide`, so the allowlist applies.
17271
17437
  */
17272
- quoter() {
17273
- return async (path, side, startLine, endLine) => {
17274
- const snap = await this.snapshotter.current();
17275
- const buf = await this.readSide(snap, path, side);
17276
- return buf == null ? null : quoteRange(buf.toString("utf8"), startLine, endLine);
17438
+ anchorSource() {
17439
+ return {
17440
+ quote: async (path, side, startLine, endLine) => {
17441
+ const snap = await this.snapshotter.current();
17442
+ const buf = await this.readSide(snap, path, side);
17443
+ return buf == null ? null : quoteRange(buf.toString("utf8"), startLine, endLine);
17444
+ },
17445
+ hasFile: async (path) => this.hasFile(await this.snapshotter.current(), path)
17277
17446
  };
17278
17447
  }
17279
17448
  readablePaths(snap) {
@@ -17325,7 +17494,7 @@ var Session = class {
17325
17494
  }
17326
17495
  };
17327
17496
  async function defaultWatcherFactory() {
17328
- const { Watcher } = await import("./Watcher-C_X03LRa.js");
17497
+ const { Watcher } = await import("./Watcher-CKwvGuPB.js");
17329
17498
  return (target) => new Watcher(target);
17330
17499
  }
17331
17500
  function readablePaths(snap) {
@@ -21013,14 +21182,16 @@ var WsHub = class {
21013
21182
  maxPayload: MAX_PAYLOAD_BYTES
21014
21183
  });
21015
21184
  clientListeners = /* @__PURE__ */ new Set();
21016
- attach(server, guard = () => true) {
21185
+ attach(server, guard = () => null) {
21017
21186
  server.on("upgrade", (req, socket, head) => {
21018
21187
  if (new URL(req.url ?? "/", "http://localhost").pathname !== "/ws") return;
21019
- if (!guard({
21188
+ const rejected = guard({
21020
21189
  host: req.headers.host,
21021
21190
  origin: req.headers.origin
21022
- })) {
21023
- socket.destroy();
21191
+ });
21192
+ if (rejected != null) {
21193
+ socket.once("finish", () => socket.destroy());
21194
+ socket.end(`HTTP/1.1 403 Forbidden\r\nConnection: close\r\nContent-Type: text/plain\r\n\r\n${rejected}\n`);
21024
21195
  return;
21025
21196
  }
21026
21197
  this.wss.handleUpgrade(req, socket, head, (ws) => {