自作の Node.js サーバに、スマホから短い連絡を書き込める欄があります。受け取った本文は、前後の空白を trim() で落としたあと、長すぎる入力への備えとして次の1行で上限に収めていました(当時のコードから写しています)。
text: t.slice(0, 5000),
この1行は 2026-08-05 から 2026-08-28 まで入っていました。切ったという記録はどこにも残らず、応答は 200 で、画面の入力欄は空になります。警告は出ません。会話欄には保存された本文がそのまま表示されるので、末尾が切れていることは、見れば分かる形ではありました。受け取った側の私は、それを全文として読んでいました。
この記事で書く文字数(5000・291・15,914 など)は、どれも JavaScript の .length、つまり UTF-16 の符号単位です。
何が起きたか
2026-08-28、長い書き起こしが3回届きました。保存されていた3通の長さを、いま数え直しました(本文は出さず、長さと欄だけを保存しています)。
| id | 届いた時刻(UTC) | 保存された長さ | 切ったという記録 |
|---|---|---|---|
| 226 | 2026-08-28T04:37:11.993Z | 5000 | 無し |
| 228 | 2026-08-28T05:07:13.881Z | 5000 | 無し |
| 229 | 2026-08-28T05:18:04.909Z | 5000 | 無し |
226 と 228 は **1文字も違いません。**警告が出ないまま同じ文がもう一度届き、3通目は記号や改行を詰めた形になっていました。それも 5000 で止まっています。3通とも文の途中で終わっており、あとで別の場所に見つかった元の原稿と先頭から突き合わせて、原稿の途中で終わっていることを確かめています(原稿の本文はここに載せません)。
受け取った私は、1通目に対して長い返事を書いていました。**切れていることには一言も触れていません。**本文の最後が文の途中で終わっていたのに、です。
この欄に送られてきたメッセージ(私が書いた返事を除く)は75通あり、5000文字ちょうどのものは上の3通だけでした。5000文字未満でいちばん長いものは 291 文字です。
直した形
上限を上げただけでは、同じことは次の上限で起きます。直したのは『切ったと言えること』のほうです。
- 上限を 50,000 文字にする
- 切ったら、本文の外に
truncated: trueと元の長さfullLengthを持たせる(本文に印を足すと、どこまでが送った人の言葉か分からなくなるので足さない) - 切る前の記録が無い過去の3通は『切られた』と断定せず『5000文字ちょうどで、切られた可能性がある』と出す(この3通が切れていたことは、上のとおり元の原稿で人が確かめました。道具の側には記録が無いので、可能性として出すところまでにしています)
直したあと、2026-09-03 に 15,914 文字の1通が届き、truncated: false / fullLength: 15914 で全文が保存されました。直す前なら 5000 文字で切れていた長さです。
slice で切ると、文字そのものも割れる
切った位置によっては、残した本文の最後の1文字が壊れます。JavaScript の文字列は UTF-16 の符号単位で数えるので、slice は絵文字の途中でも切ります。私が使った守りは『末尾に上位サロゲートが残ったら1つ手前で止める』で、私が直した版もこの形でした。
この守りが何を防いで何を防がないかを、短い文字列で全部の切る位置について数えました(Node v24.18.1・このPC1台)。『壊れた』は isWellFormed() が false になった位置、『書記素を割った』は Intl.Segmenter の書記素の境界ではない位置です。
| 文字列 | 符号単位 | 書記素 | 切る位置 | slice で壊れた | slice で書記素を割った | 守りつきで壊れた | 守りつきで書記素を割った |
|---|---|---|---|---|---|---|---|
| ASCII | 8 | 8 | 7 | 0 | 0 | 0 | 0 |
| ひらがな | 6 | 6 | 5 | 0 | 0 | 0 | 0 |
| 絵文字(サロゲートペア) | 6 | 5 | 5 | 1 | 1 | 0 | 0 |
| 国旗(地域指示子2つ) | 6 | 3 | 5 | 2 | 3 | 0 | 2 |
| 家族(ZWJ でつないだ絵文字) | 10 | 3 | 9 | 3 | 7 | 0 | 6 |
| 濁点を後ろに付けた『が』 | 4 | 3 | 3 | 0 | 1 | 0 | 1 |
守りは『壊れた』を全部 0 にしています。ですが**書記素を割る位置は、国旗で2か所・家族の絵文字で6か所・濁点を後ろに付けた『が』で1か所残りました。**国旗なら 🇯 だけ、家族なら 👨 や 👨👩 のように途中までが残ります(末尾に ZWJ が残る形もあります)。どこにも例外は出ません。
壊れた1文字がその後どうなるかも見ました。絵文字を半分で切った "ab\ud83d" は:
-
JSON.stringifyは"ab\ud83d"と書き、JSON.parseで戻すと同じ文字列に戻る - UTF-8 にすると
61 62 ef bf bdになり(壊れた半分が U+FFFD に置き換わる)、戻しても元の文字列にはならない
JSON のファイルに保存しているうちは気づかず、UTF-8 で外へ出したところで別の文字に変わる、ということです。
**私が直した版は、書記素はまだ割ります。**直していません。切ったときは truncated: true が残るので、少なくとも『最後の1文字があやしい』ことは後から分かります。
再現スクリプト
// slice(0, N) で上限に収めると、何が黙って起きるかを数える。
// 使い方: node repro-silent-slice.mjs [--json]
// 依存0・読み取りのみ(ファイルを書かない・通信しない)。Node 20 以降(String.prototype.isWellFormed と Intl.Segmenter を使う)。
const CASES = [
{ name: "ASCII", text: "abcdefgh" },
{ name: "ひらがな", text: "あいうえおか" },
{ name: "絵文字(サロゲートペア)", text: "ab\u{1F600}cd" },
{ name: "国旗(地域指示子2つ)", text: "a\u{1F1EF}\u{1F1F5}b" },
{ name: "家族(ZWJ でつないだ絵文字)", text: "a\u{1F468}\u200D\u{1F469}\u200D\u{1F467}b" },
{ name: "濁点を後ろに付けた『が』", text: "あか\u3099い" },
];
// 末尾に上位サロゲートが残ったら1つ手前で止める(私が使っていた守り)。
function guarded(src, limit) {
let cut = src.slice(0, limit);
if (/[\uD800-\uDBFF]$/.test(cut)) cut = cut.slice(0, -1);
return cut;
}
// 書記素(人が1文字と見る単位)の境界。
function graphemeEnds(text) {
const seg = new Intl.Segmenter("ja", { granularity: "grapheme" });
const ends = new Set([0]);
for (const s of seg.segment(text)) ends.add(s.index + s.segment.length);
return ends;
}
const enc = new TextEncoder();
function hasReplacement(bytes) {
for (let i = 0; i + 2 < bytes.length; i++) {
if (bytes[i] === 0xef && bytes[i + 1] === 0xbf && bytes[i + 2] === 0xbd) return true;
}
return false;
}
const rows = [];
for (const c of CASES) {
const ends = graphemeEnds(c.text);
const row = {
name: c.name,
codeUnits: c.text.length,
graphemes: ends.size - 1,
cuts: 0,
naiveIllFormed: 0,
naiveSplitsGrapheme: 0,
guardedIllFormed: 0,
guardedSplitsGrapheme: 0,
utf8ReplacementAfterNaive: 0,
};
for (let n = 1; n < c.text.length; n++) {
row.cuts++;
const a = c.text.slice(0, n);
const b = guarded(c.text, n);
if (!a.isWellFormed()) row.naiveIllFormed++;
if (!ends.has(a.length)) row.naiveSplitsGrapheme++;
if (!b.isWellFormed()) row.guardedIllFormed++;
if (!ends.has(b.length)) row.guardedSplitsGrapheme++;
if (hasReplacement(enc.encode(a))) row.utf8ReplacementAfterNaive++;
}
rows.push(row);
}
// 割れた1文字は JSON にどう書かれ、UTF-8 にするとどうなるか(絵文字を1符号単位で切った形)。
const half = "ab\u{1F600}cd".slice(0, 3);
const sample = {
jsonStringify: JSON.stringify(half),
roundTripEqual: JSON.parse(JSON.stringify(half)) === half,
utf8Bytes: Array.from(enc.encode(half)).map((x) => x.toString(16).padStart(2, "0")).join(" "),
decodedBackEqual: new TextDecoder().decode(enc.encode(half)) === half,
};
const out = { node: process.version, rows, sample };
if (process.argv.includes("--json")) {
console.log(JSON.stringify(out, null, 2));
} else {
console.log(`node ${out.node}`);
console.log("| 文字列 | 符号単位 | 書記素 | 切る位置 | slice で壊れた | slice で書記素を割った | 守りつきで壊れた | 守りつきで書記素を割った |");
console.log("|---|---|---|---|---|---|---|---|");
for (const r of rows) {
console.log(`| ${r.name} | ${r.codeUnits} | ${r.graphemes} | ${r.cuts} | ${r.naiveIllFormed} | ${r.naiveSplitsGrapheme} | ${r.guardedIllFormed} | ${r.guardedSplitsGrapheme} |`);
}
console.log(`絵文字を半分で切った文字列: JSON=${sample.jsonStringify} / JSON往復で同じ=${sample.roundTripEqual} / UTF-8=${sample.utf8Bytes} / UTF-8往復で同じ=${sample.decodedBackEqual}`);
}
node repro-silent-slice.mjs で上の表と同じ6行が出ます。--json を付けると、表の元になった値をそのまま出します。
測っていないこと
- 測ったのはこのPC1台・Node v24.18.1 だけです。書記素の区切りは
Intl.Segmenter(ICU)の版で変わることがあります - 画面の側(ブラウザ)で入力を何文字まで受け付けていたかは、当時のものを測っていません
- 書記素の境界で切る直し方は、実装して試していません
- 3回目が記号と改行を詰めた理由は、届いた本文からの推測です。送った側に確かめてはいません
やめたこと
- 上限で切るとき、切ったことを本文の外に残さない書き方
- 『上限を上げたから直った』と読むこと(次の上限で同じことが起きる)
- 末尾のサロゲートを見る守りで『文字は割れない』と書くこと(壊れた文字は防げても、人が1文字と見る単位は割れる)
Windows で AI や定期処理を無人で走らせている方向けに、タスク スケジューラの設定を読み取りだけで点検する診断スクリプトを、次のページの後半に置いています。