Stop Spreading Iterables Into Arrays to Join Them. Iterator.concat() Is Here
TL;DR: Iterator.concat(a, b, c) walks any iterables one after another and returns a lazy iterator. No temporary array, no hand-written generator, and it has been Baseline Newly available since March 2026.
The array you build just to throw away
You have an array, a Set, and a generator. You want to loop over all of them in order. So you write the line everyone writes:
for (const item of [...pinned, ...recentSet, ...loadMore()]) {
render(item);
}
It works. It also copies every item into a brand new array, runs the generator to the very end, and then hands the array to the garbage collector a moment later. If you only needed the first few items, you paid for all of them.
The careful version is a generator with yield*:
function* concat(...iterables) {
for (const iterable of iterables) {
yield* iterable;
}
}
Correct, lazy, and copy-pasted into a utils folder near you.
What it is
Iterator.concat() is that generator, built into the language. It takes any number of iterables and returns an iterator that yields from each one in turn:
const array = [1, 2, 3];
const set = new Set([4, 5]);
function* gen() {
yield 6;
yield 7;
}
console.log([...Iterator.concat(array, set, gen())]);
// [1, 2, 3, 4, 5, 6, 7]
The result is a real iterator, so the iterator helpers work on it directly:
const evensTimesTen = Iterator.concat([1, 2, 3], new Set([4, 5, 6]))
.filter((n) => n % 2 === 0)
.map((n) => n * 10)
.toArray();
console.log(evensTimesTen); // [20, 40, 60]
Maps are iterables of [key, value] pairs, which makes "defaults, then overrides" a one-liner:
const defaults = new Map([["theme", "light"], ["lang", "en"]]);
const user = new Map([["theme", "dark"]]);
const settings = new Map(Iterator.concat(defaults, user));
console.log(Object.fromEntries(settings)); // { theme: "dark", lang: "en" }
Later keys win, because that is how the Map constructor already behaves.
The realistic one: take only what you need
Here is where laziness pays. Two sources of 1000 rows each, and a UI that shows the first three:
let pulled = 0;
function* page(start) {
for (let i = start; i < start + 1000; i++) {
pulled++;
yield i;
}
}
const spread = [...page(0), ...page(1000)].slice(0, 3);
console.log(spread, pulled); // [0, 1, 2] 2000
pulled = 0;
const lazy = Iterator.concat(page(0), page(1000)).take(3).toArray();
console.log(lazy, pulled); // [0, 1, 2] 3
Same answer. The spread version produced 2000 rows to keep 3. The concat version produced 3. Replace "row" with "parsed log line" or "decoded image" and that gap stops being academic.
It is also lazy about opening each source. Logging every [Symbol.iterator]() call on two iterables gave this order:
created
open a
next 1
next 2
open b
next 3
Nothing is opened when you call concat, and b is not touched until a runs out. That also means infinite sources are fine, as long as you stop early:
function* naturals() {
let i = 1;
while (true) yield i++;
}
console.log(Iterator.concat(["start"], naturals()).take(5).toArray());
// ["start", 1, 2, 3, 4]
Can I use it?
| Where | Status |
|---|---|
| Baseline | Newly available since March 2026 |
| Chrome and Edge | 146 |
| Firefox | 147 |
| Safari (macOS and iOS) | 26.4 |
| Node.js 24 | Not yet: typeof Iterator.concat is "undefined" in v24.11.0 |
| Spec | TC39 Iterator Sequencing proposal, Stage 4 |
Everything in this post ran in Chrome 156. If you need it in Node today, keep the five-line generator from above as a fallback.
Gotchas
1. Strings are rejected, even though they are iterable. Iterator.concat([1], "ab") throws TypeError: Iterator.concat called on non-object right away, at the call, not later during iteration. null throws the same error. Wrap a string first: Iterator.from("bc") or [..."bc"] both worked.
2. Bare iterators need Iterator.from(). An object with only a next() method is not iterable, and passing one threw TypeError: #<Object> is not iterable. Built-in iterators like [1, 2, 3].values() are fine, because they are iterable too.
3. The result is single use. Spreading the same concat result twice gave [1, 2, 3] the first time and [] the second. It is an iterator, not an array, so call Iterator.concat again if you need a second pass.
4. Breaking early closes what it opened. With two generators that log in finally, a for...of that broke on the first item of the second one logged closed a when a ran out and closed b on the break. Your cleanup code runs, which is exactly what you want.
Wrap-up
Iterator.concat() is the yield* helper you kept rewriting, now with iterator helpers attached. Next time you type [...a, ...b] only to loop over it once, try Iterator.concat(a, b) instead.
Sources
- Iterator.concat() (MDN)
- Iterator Sequencing proposal (TC39)
- Finished proposals (TC39)
- Iterator browser compatibility data (MDN BCD)
This article was written with the help of AI and checked against the sources above.
2 comments
position: sticky Does Nothing? Hunt Down the Hidden Overflow
You add position: sticky and top: 0 to your header. You scroll. The header scrolls away like nothing happened.
No error in the console. No warning in DevTools. Just a header that won't stick.
This post explains the usual reason, shows a fix you can paste in, and gives you a small script that finds the problem for you.
The problem
Someone posted this in a CSS forum. Here is the gist, in our own words.
They wanted the header on their page to stay at the top while the user scrolled. They added position: sticky. Nothing changed. They had already worked through a popular checklist of "why sticky isn't working": siblings, parent height, z-index, browser support. Still no luck.
The answer that solved it was one line far away from the header: a wrapper higher up the page had overflow: hidden.
Quick version of how sticky works
A sticky element acts like a normal element until you scroll it to a set point. Then it "sticks" there, until its parent box scrolls out of view.
Two key words here:
- Scroll container: a box that can scroll its content. Elements with overflow set to hidden, auto, or scroll can be one.
- Containing block: for our purposes, the parent box that the sticky element lives inside.
Root cause 1: an ancestor with overflow: hidden
MDN says a sticky element sticks to its nearest ancestor that has a "scrolling mechanism." That is created when overflow is hidden, scroll, auto, or overlay. It sticks to that ancestor even if that ancestor never actually scrolls.
So here is what happens:
1. A wrapper has overflow: hidden, maybe to clip a rounded corner or stop a sideways scrollbar.
2. The sticky header now sticks to that wrapper, not to the page.
3. The wrapper never scrolls. The page does.
4. The header rides along with the wrapper and scrolls off the screen.
Here is a small example that shows it:
<div class="page">
<header class="header">My site</header>
<main class="content">…lots of content…</main>
</div>
.page {
overflow: hidden; /* the hidden culprit */
}
.header {
position: sticky;
top: 0;
z-index: 1;
background: white;
}
We tested this in Chrome 156 and Firefox 157. After scrolling 500px, the header's top edge was at -500px. It scrolled away completely.
Fix A: remove the overflow
If the overflow: hidden isn't doing anything important, delete it. In our test the header then stayed at the top (top edge at 0px).
Fix B: use overflow: clip instead
Often the overflow is there for a reason, like hiding content that sticks out. In that case, use clip:
.page {
overflow: clip;
}
MDN explains the difference. hidden gives the element a scrolling mechanism, so sticky treats it as a scroll container. clip also hides the overflow, but the element is not a scroll container. So the sticky header skips it and sticks to the page. In our test this fixed the header in both browsers.
The trade-off: with clip, nothing inside can be scrolled into view, not even with code. MDN warns that if there are focusable elements in the clipped area, keyboard users may not be able to reach them. Only use clip when the clipped part has nothing interactive.
overflow: clip support, per MDN's browser data: Chrome 90, Firefox 81, Safari 16.
Fix C: move the overflow somewhere else
If you need hidden (for example, you scroll that box with code), put it on an element that is not an ancestor of the sticky element. For example, wrap only the content that needs clipping, and keep the header outside that wrapper.
The sneaky one: overflow-x: hidden
Many sites add this to stop a sideways scrollbar:
.page { overflow-x: hidden; }
"But I only hid the x direction!" you say. Here is the catch. MDN explains that when one direction is visible and the other is not visible or clip, the visible one behaves as auto. So overflow-x: hidden quietly turns overflow-y into auto. Now the box is a scroll container, and sticky breaks.
The fix is to use clip for that direction:
.page { overflow-x: clip; }
clip paired with visible stays as is, so no scroll container is made. We tested both: overflow-x: hidden broke the header, and overflow-x: clip fixed it.
What about html and body?
This one surprised us, so we tested it too:
| Setup | Header sticks? (Chrome 156, Firefox 157) |
|---|---|
| overflow-x: hidden on body only | Yes |
| overflow-x: hidden on both html and body | No |
| overflow-x: clip on both html and body | Yes |
When only body has it, browsers apply that overflow to the viewport (the browser window) instead, so sticky still works. When html has it too, body keeps its own overflow and becomes the trap. If you want a rule you never have to think about, use overflow-x: clip.
Root cause 2: no offset set
MDN notes that at least one inset (like top, bottom, left, or right) must be set to something other than auto. Without it, sticky acts just like relative. We removed top: 0 in our test, and the header scrolled away.
.header {
position: sticky;
top: 0; /* required */
}
Root cause 3: the parent is too short
A sticky element can only stick while its parent box is on screen. A common setup looks like this:
<header class="site-header">
<h1>Site title</h1>
<nav class="nav">…</nav> <!-- sticky -->
</header>
<main>…</main>
The nav is sticky, but its parent is the header. Once the header scrolls away, the nav goes with it. In our test, the nav was at -440px after a 500px scroll.
The fix: move the sticky element so its parent is the tall, scrolling part of the page. Here, put the nav after the header, as a sibling of main.
How to verify
1. Find the trap with a script.
Paste this into the browser console. Change the selector to match your sticky element:
(() => {
const el = document.querySelector('.header'); // your sticky element
for (let node = el.parentElement; node; node = node.parentElement) {
const s = getComputedStyle(node);
if (/(hidden|auto|scroll|overlay)/.test(s.overflowX + ' ' + s.overflowY)) {
console.log('Sticky is trapped by:', node, s.overflowX, s.overflowY);
}
}
})();
It lists every ancestor that can trap your sticky element. Click a result to jump to it in the Elements panel. One note: if body shows up and html does not, the body's overflow may be going to the viewport. Check that case with the table above.
2. Check the computed styles.
Select a suspect element and open the Computed tab. Look at overflow-x and overflow-y. Remember that a value you didn't write (like auto) can show up because of the rule about pairs above.
3. Measure while scrolling.
Scroll down, then run:
document.querySelector('.header').getBoundingClientRect().top
If it sticks, you'll see your top value, such as 0. If you see a negative number, it scrolled away.
4. Test on a real page length.
Sticky only shows up when there's enough content to scroll. Test with real content, not a near-empty page.
Checklist
- [ ] The sticky element has top (or another inset) set.
- [ ] No ancestor has overflow: hidden, auto, or scroll, unless you want it to stick inside that box.
- [ ] Use overflow: clip or overflow-x: clip when you only need clipping.
- [ ] The sticky element's parent is tall enough to scroll through.
- [ ] Add a background and z-index so content doesn't show through or sit above it.
Sources
- MDN: position — developer.mozilla.org
- MDN: overflow — developer.mozilla.org
- MDN browser compat data (overflow: clip, position: sticky) — mdn/browser-compat-data
---
Shared by PIXEL RECRUITING LLC, where we match companies with frontend, UI and design systems engineers. Direct staffing partner for Mastercard · pixelrecruiting.com · info@pixelrecruiting.com
1 comment
Stop Spreading Bytes Into btoa(). Uint8Array Speaks Base64 Now
TL;DR: bytes.toBase64() and Uint8Array.fromBase64(str) turn bytes into base64 and back, and toHex() / fromHex() do the same for hex. They handle URL-safe base64, padding, and whitespace, and they are Baseline 2025 Newly available.
The one-liner that is secretly three bugs
You have bytes. You need base64. So you reach for the line every Stack Overflow answer hands you:
const base64 = btoa(String.fromCharCode(...bytes));
It works in the demo. Then it meets real data.
Feed btoa() a string with an emoji and Chrome throws InvalidCharacterError, because btoa() only understands Latin1. Feed the spread a 1 MB Uint8Array and Chrome 156 throws RangeError: Maximum call stack size exceeded, because every byte became a function argument. And when the token is URL-safe base64, you get the bonus round of .replace(/-/g, "+").replace(/_/g, "/") plus padding math.
Hex is no better:
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
Fine. It also looks like a sentence written by a compiler.
What it is
The TC39 "Uint8Array to/from Base64" proposal reached Stage 4 in July 2025, and TC39 lists ECMAScript 2026 as its expected publication year. It adds four methods you will use and two you will be glad exist:
- bytes.toBase64(options) returns a base64 string.
- Uint8Array.fromBase64(string, options) returns a new Uint8Array.
- bytes.toHex() returns a lowercase hex string.
- Uint8Array.fromHex(string) returns a new Uint8Array.
- bytes.setFromBase64(string) and bytes.setFromHex(string) decode into an array you already have.
The options are small:
- alphabet: "base64" (default, uses + and /) or "base64url" (uses - and _).
- omitPadding (encode only): drop the trailing =.
- lastChunkHandling (decode only): "loose" (default), "strict", or "stop-before-partial".
A minimal example
Text to base64 and back, emoji included:
const bytes = new TextEncoder().encode("héllo 👋");
const encoded = bytes.toBase64();
console.log(encoded); // "aMOpbGxvIPCfkYs="
const decoded = new TextDecoder().decode(Uint8Array.fromBase64(encoded));
console.log(decoded); // "héllo 👋"
The same 1 MB array that blew the stack above encodes to a 1,333,336 character string without complaint.
URL-safe output and padding are just options:
const b = new Uint8Array([251, 255, 191]);
console.log(b.toBase64()); // "+/+/"
console.log(b.toBase64({ alphabet: "base64url" })); // "-_-_"
const two = new Uint8Array([1, 2]);
console.log(two.toBase64()); // "AQI="
console.log(two.toBase64({ omitPadding: true })); // "AQI"
And hex, in both directions:
console.log(new Uint8Array([202, 254]).toHex()); // "cafe"
console.log(Uint8Array.fromHex("CAFE")); // Uint8Array [202, 254]
Uppercase input is accepted, and output is always lowercase.
A realistic example: hashes and tokens
Hashing a string with Web Crypto used to end in that padStart line. Now it ends in one call:
async function sha256Hex(text) {
const data = new TextEncoder().encode(text);
const digest = await crypto.subtle.digest("SHA-256", data);
return new Uint8Array(digest).toHex();
}
console.log(await sha256Hex("hello"));
// "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"
Reading the payload of a JWT, which is unpadded base64url, is just as short:
function decodeJwtPayload(token) {
const part = token.split(".")[1];
const bytes = Uint8Array.fromBase64(part, { alphabet: "base64url" });
return JSON.parse(new TextDecoder().decode(bytes));
}
// payload part: "eyJzdWIiOiI0MiIsIm5hbWUiOiJBbmEifQ"
// result: { sub: "42", name: "Ana" }
No character swapping, no padding math. Decoding is not verifying, so do not trust that payload until the signature checks out.
Every output in this post is from a run in Chrome 156.
Can I use it?
From MDN's compatibility data:
| Runtime | Version |
|---|---|
| Chrome / Edge | 140 |
| Firefox | 133 |
| Safari | 18.2 |
| Node.js | 25.0.0 |
MDN marks the methods Baseline 2025, Newly available since September 2025. Current browsers are fine; older ones need a fallback, because the methods are simply missing there.
Gotchas
1. Node 24 does not have it. On Node 24.11 (V8 13.6), typeof Uint8Array.fromBase64 is "undefined". If the same code runs on the server, check the Node version before deleting the helper.
2. It lives on Uint8Array only. ArrayBuffer.prototype.toBase64 and Uint16Array.prototype.toBase64 are both undefined. crypto.subtle.digest() returns an ArrayBuffer, so calling digest.toHex() throws TypeError: digest.toHex is not a function. Wrap it in new Uint8Array(...) first.
3. Forgetting alphabet: "base64url" works until it does not. The JWT payload above decodes fine with the default alphabet, because it happens to contain no - or _. The first token that does throws SyntaxError, which is exactly the kind of bug that waits for production. Always pass the alphabet you actually have.
4. The default decoder is forgiving. fromBase64("SGVs\nbG8=") and fromBase64("SGVsbG8") both return the bytes for "Hello": whitespace is ignored and missing padding is fine. If you are validating input rather than reading it, pass lastChunkHandling: "strict", and the unpadded version throws SyntaxError.
5. setFromBase64() only writes whole chunks. Decoding "Hello world" (11 bytes) into an 8 byte array returned { read: 8, written: 6 } and left the last two bytes as 0. It stops at the last full 3 byte group that fits, so check read before assuming the whole string went in.
6. Odd hex is an error, not a guess. Uint8Array.fromHex("abc") throws SyntaxError: Input string must contain hex characters in even length. Good. Silently padding would have been worse.
Wrap-up
btoa(String.fromCharCode(...bytes)) was always a string API wearing a bytes costume. Search your codebase for fromCharCode(... and padStart(2, "0"), and let Uint8Array do the job it was clearly meant for.
Sources
- MDN: Uint8Array.prototype.toBase64()
- MDN: Uint8Array.fromBase64()
- MDN: Uint8Array.prototype.toHex()
- MDN: Uint8Array.prototype.setFromBase64()
- MDN browser-compat-data: Uint8Array
- TC39: Uint8Array to/from Base64 proposal
- TC39: Finished proposals
This article was written with the help of AI and checked against the sources above.
Stop Writing if (!map.has(key)). Map Has getOrInsert()
TL;DR: map.getOrInsert(key, []) returns the value for key, and adds [] first if the key is missing. getOrInsertComputed() does the same with a callback, so the default is only built when it is needed. Both landed on Map and WeakMap, and they are Baseline 2026 Newly available.
The three lines everyone has typed
You have a Map and you want to group things into it. So you write the ritual:
if (!groups.has(key)) {
groups.set(key, []);
}
groups.get(key).push(item);
Three lookups to add one item. Or the shorter version that sneaks in a falsy bug the day someone stores 0:
const list = groups.get(key) || groups.set(key, []).get(key);
It works. It also looks like a riddle.
Every codebase has a getOrCreate helper for this, usually in a file called utils.js, next to three other helpers nobody remembers writing.
What it is
The TC39 "Upsert" proposal reached Stage 4 at the January 2026 meeting, and it adds two methods to both Map and WeakMap:
- getOrInsert(key, defaultValue) returns the existing value for key. If the key is missing, it stores defaultValue and returns that.
- getOrInsertComputed(key, callback) does the same, but calls callback(key) only when the key is missing, and stores what it returns.
That is the whole API. No options object, no flags.
A minimal example
Counting words, the classic:
const counts = new Map();
for (const word of ["css", "js", "css", "html", "css"]) {
counts.set(word, counts.getOrInsert(word, 0) + 1);
}
console.log([...counts]);
// [["css", 3], ["js", 1], ["html", 1]]
And the grouping ritual from above becomes one line:
const orders = [
{ id: 1, customer: "ana" },
{ id: 2, customer: "ben" },
{ id: 3, customer: "ana" },
];
const byCustomer = new Map();
for (const order of orders) {
byCustomer.getOrInsert(order.customer, []).push(order.id);
}
console.log([...byCustomer]);
// [["ana", [1, 3]], ["ben", [2]]]
Both outputs are from a run in Chrome 155.
When the default is expensive
getOrInsert() is a normal function call, so its arguments are evaluated before it runs. If the default is costly, you pay for it even when the key already exists:
let built = 0;
const make = () => {
built++;
return 42;
};
const cache = new Map([["a", 1]]);
cache.getOrInsert("a", make());
console.log(built); // 1, built for nothing
built = 0;
cache.getOrInsertComputed("a", make);
console.log(built); // 0, key exists, callback skipped
cache.getOrInsertComputed("b", make);
console.log(built); // 1, key missing, callback ran once
Rule of thumb: a literal like 0 or [] goes to getOrInsert(). Anything that does real work goes to getOrInsertComputed().
The callback also receives the key, which keeps lazy caches short:
const upper = new Map();
console.log(upper.getOrInsertComputed("hello", (key) => key.toUpperCase()));
// "HELLO"
A realistic example: per-element state in a WeakMap
The same methods exist on WeakMap, which is where they get quietly useful. Say you want to track state for DOM elements without leaking memory when they are removed:
const state = new WeakMap();
function stateFor(element) {
return state.getOrInsertComputed(element, () => ({ clicks: 0 }));
}
document.addEventListener("click", (event) => {
const button = event.target.closest("button");
if (!button) return;
const s = stateFor(button);
s.clicks++;
button.dataset.clicks = s.clicks;
});
The first click creates the state object, every later click reuses it, and when the button leaves the DOM the entry can be garbage collected. In a Chrome test page, clicking button A three times and a <span> inside button B once left data-clicks="3" and data-clicks="1", with the callback running exactly twice.
Can I use it?
From MDN's compatibility data:
| Runtime | Version |
|---|---|
| Chrome / Edge | 145 |
| Firefox | 144 |
| Safari | 26.2 |
| Node.js | 26.0.0 |
MDN marks it Baseline 2026, Newly available since February 2026. That means current browsers are fine, but anything older needs a fallback or a polyfill, since the methods simply do not exist there.
Gotchas
1. Node 24 does not have it. On Node 24.11 (V8 13.6), typeof Map.prototype.getOrInsert is "undefined". If your code also runs on the server, check your Node version before you delete that helper.
2. An existing undefined counts as existing. new Map([["x", undefined]]).getOrInsert("x", 5) returns undefined and leaves it there. The check is "does the key exist", not "is the value truthy", which is exactly what the || trick gets wrong.
3. The callback's return value wins. If the callback itself calls map.set(key, ...), the method still stores what the callback returns. In a run, the map ended up holding "returned", not the value set inside the callback, with a size of 1. Better still, do not mutate the map from inside the callback.
4. WeakMap keys still must be objects. new WeakMap().getOrInsert("str", 1) throws TypeError: Invalid value used as weak map key. The new methods do not relax the old rules.
5. The error message blames the wrong thing. Pass a non-function to getOrInsertComputed() and Chrome 155 throws TypeError: Map.prototype.getOrInsertComputed is not a function. The method exists. It is your second argument that is not a function.
Wrap-up
The has, set, get dance was always three lookups wearing a trench coat. Find your getOrCreate helper, swap it for getOrInsert() or getOrInsertComputed(), and delete one more file from utils/.
Sources
- MDN: Map.prototype.getOrInsert()
- MDN: Map.prototype.getOrInsertComputed()
- MDN: WeakMap.prototype.getOrInsertComputed()
- MDN browser-compat-data: Map
- TC39: Upsert proposal
- TC39: Finished proposals
- ECMAScript spec: Map.prototype.getOrInsert
This article was written with the help of AI and checked against the sources above.
他社サイトに置いたiframeの中でTurnstileが動くか試した
他社のサイトに script タグ 1 行で設置するウィジェットを作るとき、訪問者が bot でないかを Cloudflare Turnstile で確かめたくなります。ところが Turnstile は、widget を出すページのホスト名を事前に登録する必要があります。設置先が増えるたびに登録するのは現実的ではありません。
そこで、設置先のページには自前ドメインの iframe だけを置き、Turnstile はその iframe の中で出す形を試しました。
結論
設置先のページに置いた自前ドメインの iframe の中で、Chrome 156 と Safari 27 のどちらでも token が取れ、Siteverify は success: true でした
Siteverify が返す hostname は iframe 側の自前ドメインでした。設置先のホスト名は登録しなくて済みます
その代わり、hostname からはどのサイトに設置されたかが分かりません。設置先の判定は別に作る必要があります
設置先のページに同じ sitekey で直接出すと、エラー 110200 (Domain not authorized) になりました
Playwright で動かしたブラウザでは、headless・headed・本体の Chrome のどれでも 600010 になり、token は取れませんでした。この計測は人が普段のブラウザで押して行っています
設置先ごとの key と Origin を確かめるだけの口は、curl で Origin を付けて送ると 200 で通りました。Turnstile の token も確かめる口は、偽装だけでは 403 でした
iframe のページに frame-ancestors (key に登録したドメインだけ) と Sec-Fetch-Dest: iframe の確認を入れると、Chromium と WebKit のどちらでも、登録外のサイトに置いたときと直接開いたときは描画されませんでした
Turnstile の解決サービスと、ブラウザ以外からヘッダーを偽装して iframe のページを取る経路は残ります
ホスト名の登録の上限
Turnstile の widget には、動かすホスト名を登録します。公式のホスト名の管理のページ によると、上限は無料で 1 widget あたり 10 個、Enterprise で 200 個です。ワイルドカードは使えません (登録したホスト名のサブドメインには効きます)。
どのホスト名でも動かせる Any Hostname は Enterprise だけの機能です。
設置先が数十、数百と増えるウィジェットでは、設置先のページに直接 widget を出す形は無料では成り立ちません。iframe の中で出す形が使えるかは、公式のページには書かれていませんでした。
試した構成
自前ドメイン Y: Turnstile の widget にホスト名を登録したドメイン。この記事では widget.example.com と書きます。手元では /etc/hosts で 127.0.0.1 に向け、mkcert の証明書で https にしました
設置先 X: lvh.me。公開 DNS で 127.0.0.1 を返すドメインで、widget には登録していません。Y とは別のサイト (eTLD+1 が違う) になります
sitekey は本物を使いました。テスト用の sitekey (1x000...) はホスト名を見ないので、この確認には使えません
ページは 3 つです。
Y に直接 widget を出すページ。Y が登録済みで token が取れることの確認
X のページに Y の iframe を置き、iframe の中で widget を出すページ。これが本題です
X のページに直接 widget を出すページ。対照です
widget の結果 (token かエラー) は Y のサーバーに送り、サーバーが token ごとに Siteverify を呼んで、応答をそのまま記録しました。計測は 2026-10-24 です。
結果
普段使いの Chrome と Safari で、人がページを開いて押した結果です。
ブラウザ
ページ
token
Siteverify の success
hostname
Chrome 156
1. Y に直接
取れた
true
Y
Chrome 156
2. X の中の Y の iframe
取れた (2 回)
true
Y
Chrome 156
3. X に直接
110200
-
-
Safari 27.0
1. Y に直接
取れた
true
Y
Safari 27.0
2. X の中の Y の iframe
取れた
true
Y
iframe の中で取れた token の Siteverify の応答は次のとおりです (Safari の回。ホスト名は置き換えてあります)。
{"action":"","cdata":"","challenge_ts":"2026-10-24T12:03:55.000Z","error-codes":[],"hostname":"widget.example.com","metadata":{"interactive":false},"success":true}
metadata.interactive は、Chrome の iframe の 2 回目だけ true でした。チェックボックスを押した回です。他の回は押す前に通っています。
X に直接出したページでは、widget が再試行を続け、約 10 秒ごとに 110200 が届きました (Chrome で 9 回)。エラーコードの一覧 では 110200 は「Domain not authorized」です。
Safari の 3 番 (X に直接) は記録できていません。計測用のサーバーを、Chrome と Safari の両方で 1 番と 2 番が揃ったら止まるようにしていたため、Safari で 3 番を開く前に止まりました。110200 は Chrome と、後で書く Playwright の回で確認しています。
ここから、widget のホスト名の判定は iframe の中のページ (Y) で行われ、親のページ (X) は見ていないと読めます。iframe を cross-site に置いても、Chrome・Safari とも token の取得は妨げられませんでした。
Playwright では取れない
最初は Playwright で自動化しようとしました。Chromium の headless、本体の Chrome (channel: "chrome") の headed のどちらでも、1 番と 2 番ではチェックボックスが出て、押すと 600010 になりました。600 番台は一覧で「Generic challenge failure」です。
1 番 (Y に直接) でも同じなので、iframe のせいではなく、自動操作のブラウザが bot と判定されていると見ています。3 番は Playwright でも 110200 で、localhost に出しても 110200 でした。
Turnstile の挙動を自動テストで確かめたいときは、この点に注意が要ります。ホスト名の判定 (110200 かどうか) までは自動で見られますが、token の取得と Siteverify の中身は人が押して確かめることになります。
設置先の見分け方
Siteverify の hostname は常に Y になるので、この token がどのサイトから来たかは分かりません。設置先ごとに上限をかけたい場合などは、別の仕組みで見分ける必要があります。
設置のコードには設置先ごとの key (公開してよい値) を入れ、サーバーには key ごとに設置を許したドメインを持たせる形を考えます。ここからは、この形でどこまで守れるかを試した結果です。計測は 2026-10-24、Playwright 1.59.1 の Chromium 147 と WebKit 26.4 (どちらも headless) です。
key と Origin の確認だけでは、ブラウザ以外を止められない
ブラウザの中の JavaScript は Origin ヘッダーを書き換えられません。一方、curl などブラウザ以外のプログラムは Origin も Referer も好きな値で送れます。key はページのソースに載るので、誰でも読めます。
検証用のサーバーに、key と Origin だけを確かめる口 (/session-origin-only) と、それに Turnstile の確認を足した口 (/session) を作り、curl で送りました。key site-a に登録したドメインは lvh.me です。
curl -sk -X POST widget.example.com \
-H 'content-type: application/json' -H 'Origin: lvh.me' \
-d '{"key":"site-a"}'
送ったもの
口
ステータス
応答
Origin なし
/session
403
{"ok":false,"stage":"origin","origin":null}
Origin を偽装
/session-origin-only
200
{"ok":true,"stage":"origin-only","origin":"lvh.me"}
Origin を偽装、token なし
/session
403
Siteverify の error-codes が ["missing-input-response"]
Origin を偽装、token に forged-token
/session
403
Siteverify の error-codes が ["invalid-input-response"]
Origin を付けるだけで、key と Origin の確認は通りました。Turnstile の token も確かめる口では、偽装だけでは通れません。Siteverify は、token が無いと missing-input-response、でたらめな token だと invalid-input-response を返しました。Siteverify のページ によると token は 1 回しか検証できず、有効なのは発行から 300 秒です。
そうなると、正しい token を手に入れる道は次の 2 つです。
人がブラウザで iframe のページを開き、widget を通す
Turnstile を解く有料の解決サービスに解かせる
2 はサーバーからは見分けられず、止められないと考えています。私の設計では、IP や端末ごとの上限と、設置先ごとの月の上限を置いて、使われても被害が上限で止まるようにします。この上限の効果は測っていません。
1 は、iframe のページを登録したサイトの中でしか開けないようにすれば、狭められます。それを次に試しました。
iframe のページを登録サイトの中に限る
iframe のページ (/widget.html?key=site-a) を返すときに、次の 2 つを入れました。
Content-Security-Policy: frame-ancestors に、その key に登録したドメインだけを入れる。iframe を置けるページをブラウザが制限します。<meta> では効かないので、HTTP のヘッダーで返します
Sec-Fetch-Dest が iframe でなければ 403 にする。直接開いたときは document が届くので、iframe の外では widget を出しません
const allowed = sites[key]; // 例: { "site-a": ["lvh.me"] }
const dest = req.headers["sec-fetch-dest"] ?? null;
if (!allowed) { res.writeHead(404); res.end(); return; }
if (dest !== "iframe") { res.writeHead(403); res.end("iframe の中でだけ開けます"); return; }
res.writeHead(200, {
"content-type": "text/html; charset=utf-8",
"content-security-policy": frame-ancestors ${allowed.join(" ")},
});
試したのは 3 通りです。
登録サイトの中: lvh.me に iframe を置く
登録外サイトの中: localhost に同じ iframe を置く。lvh.me とは別のサイトです
直接開く: widget.example.com をタブで開く
Turnstile の本物の token は自動操作では取れない (600010) ので、iframe のページが描画されたかで判定しました。iframe のページには目印の要素を置き、中で Turnstile のテスト用 sitekey (1x000...、常に通る) の widget を出しています。
ブラウザ
開き方
サーバーに届いた Sec-Fetch-Dest
応答
iframe のページの描画
Chromium 147
登録サイトの中
iframe
200
描画された (目印あり、テスト用 widget の callback も呼ばれた)
Chromium 147
登録外サイトの中
iframe
200
描画されない (frame の URL は chrome-error://chromewebdata/)
Chromium 147
直接開く
document
403
描画されない (本文は「iframe の中でだけ開けます」)
WebKit 26.4
登録サイトの中
iframe
200
描画された (目印あり、テスト用 widget の callback も呼ばれた)
WebKit 26.4
登録外サイトの中
iframe
200
描画されない (frame の中身が空)
WebKit 26.4
直接開く
document
403
描画されない (本文は「iframe の中でだけ開けます」)
登録外サイトの中に置いたときのコンソールは次のとおりです。
Chromium: Framing 'widget.example.com' violates the following Content Security Policy directive: "frame-ancestors lvh.me". The request has been blocked.
WebKit: Refused to load widget.example.com because it does not appear in the frame-ancestors directive of the Content Security Policy.
登録外サイトの中でも、サーバーは 200 を返しています。frame-ancestors はブラウザが応答を受け取ってから表示を止める仕組みで、サーバーはどこに置かれたかを知らずに返します。サーバーには Sec-Fetch-Site: cross-site と Referer: localhost が届いていましたが、Referer は設置先の Referrer-Policy で削られることがあるので、判定には使いませんでした。
Sec-Fetch-Dest を送らないブラウザ
MDN の互換性データ では、Sec-Fetch-Dest は Chrome 80、Firefox 90、Safari 16.4 からです。また MDN によると、このヘッダーは potentially trustworthy な URL (https など) へのリクエストにしか付きません。iframe のページは https で返す必要があります。
今回のコードは、ヘッダーが無いときも 403 にしています。Safari 16.4 より前などのブラウザでは widget を出せません。ヘッダーが無いときだけ通す書き方もできますが、その場合、古いブラウザでは直接開けてしまいます。今回の WebKit 26.4 は iframe と document を送ってきました。手元の Safari の実物では確かめていません。
frame-ancestors は互換性データで Chrome 40、Firefox 58、Safari 10 からです。
別の key への token の流用を防ぐ: cData
key ごとに上限をかけるなら、ある key の iframe で取った token を、別の key の会話に使われるのも防ぎたくなります。Turnstile の widget には cData を渡せ、Siteverify の応答の cdata で返ってきます。widget の設定のページ によると、最大 255 文字で、英数字と _・- だけが使えます。
turnstile.render("#w", { sitekey, cData: "site-a", callback: (token) => { /* サーバーへ送る */ } });
const result = await siteverify(token);
if (result.success !== true || result.cdata !== input.key) { /* 403 */ }
前半の計測の Siteverify の応答にも "cdata":"" の項目がありました (このときは cData を渡していません)。本物の sitekey の token は自動操作では取れないため、cData の照合は計測していません。
それでも残る経路
解決サービスに解かせる経路は、上の対策では止まりません
ブラウザ以外のプログラムは、Sec-Fetch-Dest: iframe も付けられます。curl で付けて送ると、iframe のページが 200 で返りました (付けないと 403)。frame-ancestors はブラウザが守るものなので、ブラウザ以外には効きません。取ったページを解決サービスに渡せば、正しい key の cData が入った token が取れると考えられます
frame-ancestors と Sec-Fetch-Dest で狭められるのは、人が普通のブラウザで登録外の場所から widget を通す経路までです。残りは上限で被害を抑えることになります。
測れていないこと
X は http で試しました。実際の設置先は https が普通ですが、https のページには置いていません
Firefox と iOS の Safari は試していません
Safari の「サイト越えトラッキングを防ぐ」は既定のままです。設定を変えた場合は試していません
iframe の行に親のページを記録しておらず、2 番の結果が X の中から来たことは、開いた手順でしか示せません。公開したコードでは記録を足してあります
frame-ancestors と Sec-Fetch-Dest は Playwright の Chromium と WebKit で測りました。普段使いの Chrome と Safari では試していません
cData の照合と、IP・端末ごと・設置先ごとの上限の効果は測っていません
コード
検証用のサーバーとページ、手で押す版と Playwright 版、対策の計測版 (src/protect.ts)、計測の結果は blog-examples に置いてあります。Turnstile の widget と、自前ドメインを 127.0.0.1 に向ける設定と証明書があれば動きます。
Twitter Added id_str in 2010 Because JavaScript Can't Hold a Java long
- Book: Kotlin and Java to TypeScript
- Also by me: The TypeScript Library — the 5-book collection
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
---
On October 18, 2010, Twitter told every developer on its API that tweet IDs were about to change shape. A new generator called Snowflake would produce 64-bit IDs, and TechCrunch's write-up of the notice put the date plainly: "on November 26, these IDs will go longer than 53 bits." The fix Twitter shipped was blunt. Starting October 22, every ID in a JSON response would come back twice, once as a number and once as a string. In the words of developer advocate Matt Harris, quoted in the same article: "To allow javascript and JSON parsers to read the IDs we need to include a string version of any ID."
That string field is id_str, and it is still in the API. The current X developer docs tell v1.1 clients to "always use id_str", and v2 skipped the number entirely: IDs come back as strings by default.
Sixteen years later, the same bug still ships. A Spring Boot or Ktor service returns a Long ID, a TypeScript client calls JSON.parse, and the last few digits change. No exception. No warning in the console. The client then sends the wrong ID back to the server, and you get a 404 on a record that exists, or a hit on a different record whose ID happens to be the rounded value.
Why Twitter needed 64 bits in the first place
Snowflake was built for Twitter's move from MySQL to Cassandra. The original README is direct about the motive: "there is no sequential id generation facility in Cassandra, nor should there be." The requirements were uncoordinated generation across machines, rough time ordering, and IDs that stay under 64 bits.
The layout it settled on:
- 41 bits of millisecond timestamp against a custom epoch ("gives us 69 years")
- 10 bits of machine ID, for up to 1024 machines
- 12 bits of sequence number, rolling over every 4096 IDs per machine
That is 63 bits of payload, which fits a signed 64-bit integer: a Java long, a Kotlin Long, a Scala Long. The Java Language Specification gives long the range -9223372036854775808 to 9223372036854775807. Every JVM backend can hold a Snowflake ID without thinking about it. The trouble starts when the ID leaves the JVM.
!A 64-bit integer bar being squeezed into a shorter 53-slot container, with the extra bits spilling off the end
A JVM long has 64 bits to spend. A JavaScript number has 53 for the integer part.
53 bits of mantissa versus 64 bits of long
A JavaScript number is an IEEE 754 double. MDN's page on Number.MAX_SAFE_INTEGER spells out the consequence: a double "only has 52 bits to represent the mantissa," plus one implicit leading bit, so it can only safely represent integers between -(2^53 - 1) and 2^53 - 1. That ceiling is 9007199254740991.
Past it, a double still exists for every power of two, but not for every integer. Just above 2^53 the gap between neighbouring doubles is 2. Just above 2^60 it is 2^(60-52), which is 256. Modern Snowflake IDs live up there, so a parsed ID snaps to the nearest representable double, and two real IDs generated a moment apart can land on the same one.
You can see it in four lines:
const body = '{"id": 9007199254740993}';
const order = JSON.parse(body) as { id: number };
console.log(order.id); // 9007199254740992
console.log(Number.isSafeInteger(order.id)); // false
MDN shows the same collapse from the arithmetic side: Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 evaluates to true. The X docs use 10765432100123456789, which is past even a Java long's ceiling, and show it printing back as 10765432100123458000.
None of this breaks the JSON spec. RFC 8259, section 6 "allows implementations to set limits on the range and precision of numbers accepted," and names only the integers in [-(2^53)+1, (2^53)-1] as the range where implementations "will agree exactly on their numeric values." Jackson writing 9007199254740993 is valid JSON. JSON.parse rounding it is also valid. The contract between them is the bug.
TypeScript does not help here. JSON.parse returns any, your as { id: number } is a promise you made to the compiler, and the value already satisfies number. The type system checks the shape, and the shape is fine.
Why BigInt doesn't fix JSON.parse
The natural reaction from a JVM developer is: JavaScript has arbitrary-precision integers now, so parse into BigInt. Two things get in the way.
First, JSON.parse never produces a BigInt. Numbers become doubles during parsing, before your code sees anything. The MDN JSON.parse page is explicit that the reviver "is run after the value is parsed," so numbers "will have already been converted to JavaScript numbers, and may lose precision in the process." Writing BigInt(value) inside a classic two-argument reviver gives you an exact BigInt of the wrong number.
Second, the trip back is closed by default. Per MDN's BigInt page, calling JSON.stringify() on any value containing a BigInt throws a TypeError unless you supply a toJSON or a replacer.
So BigInt is a fine type to hold an ID once you have the exact digits. It cannot recover digits the parser already threw away. The digits have to survive the parse, which means either they arrive as a string, or the parser hands you the source text.
!Two different ID cards on a conveyor belt merging into a single card as they pass through a funnel
Above 2^53, distinct IDs can parse to the same JavaScript number.
Fix it on the JVM side: make the ID a string in the contract
The fix Twitter chose, and the one Discord documents for its own Snowflake IDs ("they are always returned as strings in the HTTP API"), is to put the ID on the wire as a JSON string. Keep Long in the database and in your domain model. Change only the serialized form.
With Jackson, a per-field annotation does it:
import com.fasterxml.jackson.annotation.JsonFormat;
public record OrderDto(
@JsonFormat(shape = JsonFormat.Shape.STRING)
long id,
long totalCents
) {}
In Jackson 2, the number serializers return ToStringSerializer.instance when the shape is STRING, so id goes out as "9007199254740993" with the quotes. The annotation lives in jackson-annotations, which the Spring team notes kept its package unchanged when Spring Boot 4 moved to Jackson 3 under tools.jackson, so the same line works across that upgrade.
If every Long in your API is an identifier, you can register the serializer by type instead. On Spring Boot 3 with Jackson 2:
@Bean
Jackson2ObjectMapperBuilderCustomizer longsAsStrings() {
return b -> b
.serializerByType(Long.class, ToStringSerializer.instance)
.serializerByType(Long.TYPE, ToStringSerializer.instance);
}
Be careful with that one. It also quotes counts, cents and timestamps that never needed it. Jackson's blunter switch, JsonWriteFeature.WRITE_NUMBERS_AS_STRINGS (off by default), "forces all regular number values to be written as JSON Strings," doubles included. Per field is usually the honest scope.
On Ktor with kotlinx.serialization, the library ships a serializer for exactly this case. The LongAsStringSerializer docs describe it as "intended to be used for interoperability with external clients (mainly JavaScript ones)":
import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.LongAsStringSerializer
@Serializable
data class OrderDto(
@Serializable(with = LongAsStringSerializer::class)
val id: Long,
val totalCents: Long,
)
Then write it down. If you publish an OpenAPI spec, the ID is type: string, not type: integer, format: int64, so generated TypeScript clients get string too.
Fix it in the TypeScript client
On the client, type the ID as what it is on the wire: a string. A branded type keeps it from mixing with other strings:
type OrderId = string & { readonly __brand: "OrderId" };
interface Order {
id: OrderId;
totalCents: number;
}
You compare it, put it in URLs and send it back. You almost never do arithmetic on an ID, so a string loses nothing. If you need ordering or math, convert with BigInt(order.id) at that moment, from exact digits.
Sometimes you don't own the server and it still sends bare numbers. For that case, TC39 added source text access to JSON.parse; the proposal is at stage 4. For primitive values, the reviver gets a third context argument whose source property is "the original JSON string representing this value" (MDN). MDN's compatibility data lists it in Chrome 114, Firefox 135, Safari 18.4, Node.js 21, Deno 1.33 and Bun 1.1.43.
type Ctx = { source?: string };
function idsAsStrings(key: string, value: unknown, ctx?: Ctx) {
if (key !== "id" || typeof value !== "number") return value;
if (ctx?.source === undefined) {
throw new Error("JSON.parse source access not supported");
}
return ctx.source; // the exact digits, before rounding
}
const order = JSON.parse(text, idsAsStrings) as Order;
The reviver throws when the runtime doesn't pass context, because falling back to String(value) would hand you the rounded digits as a string, which is the original bug wearing a disguise. And you need the raw text to call it: Response.json() takes no reviver, so read the body with await res.text() and parse it yourself.
The same proposal added JSON.rawJSON for the outbound direction. A replacer like typeof v === "bigint" ? JSON.rawJSON(v.toString()) : v lets JSON.stringify emit a BigInt as a bare JSON number without precision loss, when a server insists on numeric IDs in request bodies.
If you own the server, the one-line @JsonFormat change is the whole fix, and it costs less to ship before your ID generator crosses 2^53 than after the first 404 on a record that exists.
---
If this was useful
This bug lives exactly on the seam between the JVM and TypeScript: a Long that is perfectly safe on one side and quietly lossy on the other. Kotlin and Java to TypeScript is the book I wrote for that seam, mapping what you already know from Java and Kotlin (variance, null safety, sealed hierarchies, coroutines) onto how TypeScript does the same jobs.
It's part of The TypeScript Library, five books with a clear path through them. Books 1 and 2 are the core path. If you come from the JVM, book 3 stands in for book 1. If you ship TypeScript at work, book 5 is the production layer:
- TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling
- The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types
- Kotlin and Java to TypeScript — JVM developers moving to TypeScript
- PHP to TypeScript — PHP 8+ developers moving to TypeScript
- TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR
All five books ship in ebook, paperback, and hardcover.

1 reaction · 1 comment
While drafting this month's issue I checked alpha() against the browsers rather than the compat data. BCD said Chrome 151. 151 does not parse it; 152 does. wpt.fyi and chromestatus agreed. I filed it, and MDN fixed it today.
browser-compat-data#30731
8 views
While drafting this month's issue I checked alpha() against the browsers rather than the compat data. BCD said Chrome 151. 151 does not parse it; 152 does. wpt.fyi and chromestatus agreed. I filed it, and MDN fixed it today.
browser-compat-data#30731
Open source contribution #2 is in.
Last month: fixed a typo on MDN’s JavaScript docs.
This month: the CSS image() page — “spacial” → “spatial”.
Design systems week, CSS docs fix. Fate.
@devanddesignhq #BuildInPublic
content#46116
2 likes · 139 views
No, but it's on the way content#45834
2 likes
Mentions
Stop Spreading Iterables Into Arrays to Join Them. Iterator.concat() Is Here TL;DR: Iterator.concat(a, b, c) walks any iterables one after another and returns a lazy iterator. No temporary array, no hand-written generator, and it has been Baseline Newly available since March 2026. The array you build just to throw away You have an array, a Set, and a generator. You want to loop over all of them in order. So you write the line everyone writes: for (const item of [...pinned, ...recentSet, ...loadMore()]) { render(item); } It works. It also copies every item into a brand new array, runs the generator to the very end, and then hands the array to the garbage collector a moment later. If you only needed the first few items, you paid for all of them. The careful version is a generator with yield*: function* concat(...iterables) { for (const iterable of iterables) { yield* iterable; } } Correct, lazy, and copy-pasted into a utils folder near you. What it is Iterator.concat() is that generator, built into the language. It takes any number of iterables and returns an iterator that yields from each one in turn: const array = [1, 2, 3]; const set = new Set([4, 5]); function* gen() { yield 6; yield 7; } console.log([...Iterator.concat(array, set, gen())]); // [1, 2, 3, 4, 5, 6, 7] The result is a real iterator, so the iterator helpers work on it directly: const evensTimesTen = Iterator.concat([1, 2, 3], new Set([4, 5, 6])) .filter((n) => n % 2 === 0) .map((n) => n * 10) .toArray(); console.log(evensTimesTen); // [20, 40, 60] Maps are iterables of [key, value] pairs, which makes "defaults, then overrides" a one-liner: const defaults = new Map([["theme", "light"], ["lang", "en"]]); const user = new Map([["theme", "dark"]]); const settings = new Map(Iterator.concat(defaults, user)); console.log(Object.fromEntries(settings)); // { theme: "dark", lang: "en" } Later keys win, because that is how the Map constructor already behaves. The realistic one: take only what you need Here is where laziness pays. Two sources of 1000 rows each, and a UI that shows the first three: let pulled = 0; function* page(start) { for (let i = start; i < start + 1000; i++) { pulled++; yield i; } } const spread = [...page(0), ...page(1000)].slice(0, 3); console.log(spread, pulled); // [0, 1, 2] 2000 pulled = 0; const lazy = Iterator.concat(page(0), page(1000)).take(3).toArray(); console.log(lazy, pulled); // [0, 1, 2] 3 Same answer. The spread version produced 2000 rows to keep 3. The concat version produced 3. Replace "row" with "parsed log line" or "decoded image" and that gap stops being academic. It is also lazy about opening each source. Logging every [Symbol.iterator]() call on two iterables gave this order: created open a next 1 next 2 open b next 3 Nothing is opened when you call concat, and b is not touched until a runs out. That also means infinite sources are fine, as long as you stop early: function* naturals() { let i = 1; while (true) yield i++; } console.log(Iterator.concat(["start"], naturals()).take(5).toArray()); // ["start", 1, 2, 3, 4] Can I use it? | Where | Status | |---|---| | Baseline | Newly available since March 2026 | | Chrome and Edge | 146 | | Firefox | 147 | | Safari (macOS and iOS) | 26.4 | | Node.js 24 | Not yet: typeof Iterator.concat is "undefined" in v24.11.0 | | Spec | TC39 Iterator Sequencing proposal, Stage 4 | Everything in this post ran in Chrome 156. If you need it in Node today, keep the five-line generator from above as a fallback. Gotchas 1. Strings are rejected, even though they are iterable. Iterator.concat([1], "ab") throws TypeError: Iterator.concat called on non-object right away, at the call, not later during iteration. null throws the same error. Wrap a string first: Iterator.from("bc") or [..."bc"] both worked. 2. Bare iterators need Iterator.from(). An object with only a next() method is not iterable, and passing one threw TypeError: #<Object> is not iterable. Built-in iterators like [1, 2, 3].values() are fine, because they are iterable too. 3. The result is single use. Spreading the same concat result twice gave [1, 2, 3] the first time and [] the second. It is an iterator, not an array, so call Iterator.concat again if you need a second pass. 4. Breaking early closes what it opened. With two generators that log in finally, a for...of that broke on the first item of the second one logged closed a when a ran out and closed b on the break. Your cleanup code runs, which is exactly what you want. Wrap-up Iterator.concat() is the yield* helper you kept rewriting, now with iterator helpers attached. Next time you type [...a, ...b] only to loop over it once, try Iterator.concat(a, b) instead. Sources - Iterator.concat() (MDN) - Iterator Sequencing proposal (TC39) - Finished proposals (TC39) - Iterator browser compatibility data (MDN BCD) This article was written with the help of AI and checked against the sources above.
position: sticky Does Nothing? Hunt Down the Hidden Overflow You add position: sticky and top: 0 to your header. You scroll. The header scrolls away like nothing happened. No error in the console. No warning in DevTools. Just a header that won't stick. This post explains the usual reason, shows a fix you can paste in, and gives you a small script that finds the problem for you. The problem Someone posted this in a CSS forum. Here is the gist, in our own words. They wanted the header on their page to stay at the top while the user scrolled. They added position: sticky. Nothing changed. They had already worked through a popular checklist of "why sticky isn't working": siblings, parent height, z-index, browser support. Still no luck. The answer that solved it was one line far away from the header: a wrapper higher up the page had overflow: hidden. Quick version of how sticky works A sticky element acts like a normal element until you scroll it to a set point. Then it "sticks" there, until its parent box scrolls out of view. Two key words here: - Scroll container: a box that can scroll its content. Elements with overflow set to hidden, auto, or scroll can be one. - Containing block: for our purposes, the parent box that the sticky element lives inside. Root cause 1: an ancestor with overflow: hidden MDN says a sticky element sticks to its nearest ancestor that has a "scrolling mechanism." That is created when overflow is hidden, scroll, auto, or overlay. It sticks to that ancestor even if that ancestor never actually scrolls. So here is what happens: 1. A wrapper has overflow: hidden, maybe to clip a rounded corner or stop a sideways scrollbar. 2. The sticky header now sticks to that wrapper, not to the page. 3. The wrapper never scrolls. The page does. 4. The header rides along with the wrapper and scrolls off the screen. Here is a small example that shows it: <div class="page"> <header class="header">My site</header> <main class="content">…lots of content…</main> </div> .page { overflow: hidden; /* the hidden culprit */ } .header { position: sticky; top: 0; z-index: 1; background: white; } We tested this in Chrome 156 and Firefox 157. After scrolling 500px, the header's top edge was at -500px. It scrolled away completely. Fix A: remove the overflow If the overflow: hidden isn't doing anything important, delete it. In our test the header then stayed at the top (top edge at 0px). Fix B: use overflow: clip instead Often the overflow is there for a reason, like hiding content that sticks out. In that case, use clip: .page { overflow: clip; } MDN explains the difference. hidden gives the element a scrolling mechanism, so sticky treats it as a scroll container. clip also hides the overflow, but the element is not a scroll container. So the sticky header skips it and sticks to the page. In our test this fixed the header in both browsers. The trade-off: with clip, nothing inside can be scrolled into view, not even with code. MDN warns that if there are focusable elements in the clipped area, keyboard users may not be able to reach them. Only use clip when the clipped part has nothing interactive. overflow: clip support, per MDN's browser data: Chrome 90, Firefox 81, Safari 16. Fix C: move the overflow somewhere else If you need hidden (for example, you scroll that box with code), put it on an element that is not an ancestor of the sticky element. For example, wrap only the content that needs clipping, and keep the header outside that wrapper. The sneaky one: overflow-x: hidden Many sites add this to stop a sideways scrollbar: .page { overflow-x: hidden; } "But I only hid the x direction!" you say. Here is the catch. MDN explains that when one direction is visible and the other is not visible or clip, the visible one behaves as auto. So overflow-x: hidden quietly turns overflow-y into auto. Now the box is a scroll container, and sticky breaks. The fix is to use clip for that direction: .page { overflow-x: clip; } clip paired with visible stays as is, so no scroll container is made. We tested both: overflow-x: hidden broke the header, and overflow-x: clip fixed it. What about html and body? This one surprised us, so we tested it too: | Setup | Header sticks? (Chrome 156, Firefox 157) | |---|---| | overflow-x: hidden on body only | Yes | | overflow-x: hidden on both html and body | No | | overflow-x: clip on both html and body | Yes | When only body has it, browsers apply that overflow to the viewport (the browser window) instead, so sticky still works. When html has it too, body keeps its own overflow and becomes the trap. If you want a rule you never have to think about, use overflow-x: clip. Root cause 2: no offset set MDN notes that at least one inset (like top, bottom, left, or right) must be set to something other than auto. Without it, sticky acts just like relative. We removed top: 0 in our test, and the header scrolled away. .header { position: sticky; top: 0; /* required */ } Root cause 3: the parent is too short A sticky element can only stick while its parent box is on screen. A common setup looks like this: <header class="site-header"> <h1>Site title</h1> <nav class="nav">…</nav> <!-- sticky --> </header> <main>…</main> The nav is sticky, but its parent is the header. Once the header scrolls away, the nav goes with it. In our test, the nav was at -440px after a 500px scroll. The fix: move the sticky element so its parent is the tall, scrolling part of the page. Here, put the nav after the header, as a sibling of main. How to verify 1. Find the trap with a script. Paste this into the browser console. Change the selector to match your sticky element: (() => { const el = document.querySelector('.header'); // your sticky element for (let node = el.parentElement; node; node = node.parentElement) { const s = getComputedStyle(node); if (/(hidden|auto|scroll|overlay)/.test(s.overflowX + ' ' + s.overflowY)) { console.log('Sticky is trapped by:', node, s.overflowX, s.overflowY); } } })(); It lists every ancestor that can trap your sticky element. Click a result to jump to it in the Elements panel. One note: if body shows up and html does not, the body's overflow may be going to the viewport. Check that case with the table above. 2. Check the computed styles. Select a suspect element and open the Computed tab. Look at overflow-x and overflow-y. Remember that a value you didn't write (like auto) can show up because of the rule about pairs above. 3. Measure while scrolling. Scroll down, then run: document.querySelector('.header').getBoundingClientRect().top If it sticks, you'll see your top value, such as 0. If you see a negative number, it scrolled away. 4. Test on a real page length. Sticky only shows up when there's enough content to scroll. Test with real content, not a near-empty page. Checklist - [ ] The sticky element has top (or another inset) set. - [ ] No ancestor has overflow: hidden, auto, or scroll, unless you want it to stick inside that box. - [ ] Use overflow: clip or overflow-x: clip when you only need clipping. - [ ] The sticky element's parent is tall enough to scroll through. - [ ] Add a background and z-index so content doesn't show through or sit above it. Sources - MDN: position — developer.mozilla.org - MDN: overflow — developer.mozilla.org - MDN browser compat data (overflow: clip, position: sticky) — mdn/browser-compat-data --- Shared by PIXEL RECRUITING LLC, where we match companies with frontend, UI and design systems engineers. Direct staffing partner for Mastercard · pixelrecruiting.com · info@pixelrecruiting.com
Stop Spreading Bytes Into btoa(). Uint8Array Speaks Base64 Now TL;DR: bytes.toBase64() and Uint8Array.fromBase64(str) turn bytes into base64 and back, and toHex() / fromHex() do the same for hex. They handle URL-safe base64, padding, and whitespace, and they are Baseline 2025 Newly available. The one-liner that is secretly three bugs You have bytes. You need base64. So you reach for the line every Stack Overflow answer hands you: const base64 = btoa(String.fromCharCode(...bytes)); It works in the demo. Then it meets real data. Feed btoa() a string with an emoji and Chrome throws InvalidCharacterError, because btoa() only understands Latin1. Feed the spread a 1 MB Uint8Array and Chrome 156 throws RangeError: Maximum call stack size exceeded, because every byte became a function argument. And when the token is URL-safe base64, you get the bonus round of .replace(/-/g, "+").replace(/_/g, "/") plus padding math. Hex is no better: const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(""); Fine. It also looks like a sentence written by a compiler. What it is The TC39 "Uint8Array to/from Base64" proposal reached Stage 4 in July 2025, and TC39 lists ECMAScript 2026 as its expected publication year. It adds four methods you will use and two you will be glad exist: - bytes.toBase64(options) returns a base64 string. - Uint8Array.fromBase64(string, options) returns a new Uint8Array. - bytes.toHex() returns a lowercase hex string. - Uint8Array.fromHex(string) returns a new Uint8Array. - bytes.setFromBase64(string) and bytes.setFromHex(string) decode into an array you already have. The options are small: - alphabet: "base64" (default, uses + and /) or "base64url" (uses - and _). - omitPadding (encode only): drop the trailing =. - lastChunkHandling (decode only): "loose" (default), "strict", or "stop-before-partial". A minimal example Text to base64 and back, emoji included: const bytes = new TextEncoder().encode("héllo 👋"); const encoded = bytes.toBase64(); console.log(encoded); // "aMOpbGxvIPCfkYs=" const decoded = new TextDecoder().decode(Uint8Array.fromBase64(encoded)); console.log(decoded); // "héllo 👋" The same 1 MB array that blew the stack above encodes to a 1,333,336 character string without complaint. URL-safe output and padding are just options: const b = new Uint8Array([251, 255, 191]); console.log(b.toBase64()); // "+/+/" console.log(b.toBase64({ alphabet: "base64url" })); // "-_-_" const two = new Uint8Array([1, 2]); console.log(two.toBase64()); // "AQI=" console.log(two.toBase64({ omitPadding: true })); // "AQI" And hex, in both directions: console.log(new Uint8Array([202, 254]).toHex()); // "cafe" console.log(Uint8Array.fromHex("CAFE")); // Uint8Array [202, 254] Uppercase input is accepted, and output is always lowercase. A realistic example: hashes and tokens Hashing a string with Web Crypto used to end in that padStart line. Now it ends in one call: async function sha256Hex(text) { const data = new TextEncoder().encode(text); const digest = await crypto.subtle.digest("SHA-256", data); return new Uint8Array(digest).toHex(); } console.log(await sha256Hex("hello")); // "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824" Reading the payload of a JWT, which is unpadded base64url, is just as short: function decodeJwtPayload(token) { const part = token.split(".")[1]; const bytes = Uint8Array.fromBase64(part, { alphabet: "base64url" }); return JSON.parse(new TextDecoder().decode(bytes)); } // payload part: "eyJzdWIiOiI0MiIsIm5hbWUiOiJBbmEifQ" // result: { sub: "42", name: "Ana" } No character swapping, no padding math. Decoding is not verifying, so do not trust that payload until the signature checks out. Every output in this post is from a run in Chrome 156. Can I use it? From MDN's compatibility data: | Runtime | Version | |---|---| | Chrome / Edge | 140 | | Firefox | 133 | | Safari | 18.2 | | Node.js | 25.0.0 | MDN marks the methods Baseline 2025, Newly available since September 2025. Current browsers are fine; older ones need a fallback, because the methods are simply missing there. Gotchas 1. Node 24 does not have it. On Node 24.11 (V8 13.6), typeof Uint8Array.fromBase64 is "undefined". If the same code runs on the server, check the Node version before deleting the helper. 2. It lives on Uint8Array only. ArrayBuffer.prototype.toBase64 and Uint16Array.prototype.toBase64 are both undefined. crypto.subtle.digest() returns an ArrayBuffer, so calling digest.toHex() throws TypeError: digest.toHex is not a function. Wrap it in new Uint8Array(...) first. 3. Forgetting alphabet: "base64url" works until it does not. The JWT payload above decodes fine with the default alphabet, because it happens to contain no - or _. The first token that does throws SyntaxError, which is exactly the kind of bug that waits for production. Always pass the alphabet you actually have. 4. The default decoder is forgiving. fromBase64("SGVs\nbG8=") and fromBase64("SGVsbG8") both return the bytes for "Hello": whitespace is ignored and missing padding is fine. If you are validating input rather than reading it, pass lastChunkHandling: "strict", and the unpadded version throws SyntaxError. 5. setFromBase64() only writes whole chunks. Decoding "Hello world" (11 bytes) into an 8 byte array returned { read: 8, written: 6 } and left the last two bytes as 0. It stops at the last full 3 byte group that fits, so check read before assuming the whole string went in. 6. Odd hex is an error, not a guess. Uint8Array.fromHex("abc") throws SyntaxError: Input string must contain hex characters in even length. Good. Silently padding would have been worse. Wrap-up btoa(String.fromCharCode(...bytes)) was always a string API wearing a bytes costume. Search your codebase for fromCharCode(... and padStart(2, "0"), and let Uint8Array do the job it was clearly meant for. Sources - MDN: Uint8Array.prototype.toBase64() - MDN: Uint8Array.fromBase64() - MDN: Uint8Array.prototype.toHex() - MDN: Uint8Array.prototype.setFromBase64() - MDN browser-compat-data: Uint8Array - TC39: Uint8Array to/from Base64 proposal - TC39: Finished proposals This article was written with the help of AI and checked against the sources above.
Stop Writing if (!map.has(key)). Map Has getOrInsert() TL;DR: map.getOrInsert(key, []) returns the value for key, and adds [] first if the key is missing. getOrInsertComputed() does the same with a callback, so the default is only built when it is needed. Both landed on Map and WeakMap, and they are Baseline 2026 Newly available. The three lines everyone has typed You have a Map and you want to group things into it. So you write the ritual: if (!groups.has(key)) { groups.set(key, []); } groups.get(key).push(item); Three lookups to add one item. Or the shorter version that sneaks in a falsy bug the day someone stores 0: const list = groups.get(key) || groups.set(key, []).get(key); It works. It also looks like a riddle. Every codebase has a getOrCreate helper for this, usually in a file called utils.js, next to three other helpers nobody remembers writing. What it is The TC39 "Upsert" proposal reached Stage 4 at the January 2026 meeting, and it adds two methods to both Map and WeakMap: - getOrInsert(key, defaultValue) returns the existing value for key. If the key is missing, it stores defaultValue and returns that. - getOrInsertComputed(key, callback) does the same, but calls callback(key) only when the key is missing, and stores what it returns. That is the whole API. No options object, no flags. A minimal example Counting words, the classic: const counts = new Map(); for (const word of ["css", "js", "css", "html", "css"]) { counts.set(word, counts.getOrInsert(word, 0) + 1); } console.log([...counts]); // [["css", 3], ["js", 1], ["html", 1]] And the grouping ritual from above becomes one line: const orders = [ { id: 1, customer: "ana" }, { id: 2, customer: "ben" }, { id: 3, customer: "ana" }, ]; const byCustomer = new Map(); for (const order of orders) { byCustomer.getOrInsert(order.customer, []).push(order.id); } console.log([...byCustomer]); // [["ana", [1, 3]], ["ben", [2]]] Both outputs are from a run in Chrome 155. When the default is expensive getOrInsert() is a normal function call, so its arguments are evaluated before it runs. If the default is costly, you pay for it even when the key already exists: let built = 0; const make = () => { built++; return 42; }; const cache = new Map([["a", 1]]); cache.getOrInsert("a", make()); console.log(built); // 1, built for nothing built = 0; cache.getOrInsertComputed("a", make); console.log(built); // 0, key exists, callback skipped cache.getOrInsertComputed("b", make); console.log(built); // 1, key missing, callback ran once Rule of thumb: a literal like 0 or [] goes to getOrInsert(). Anything that does real work goes to getOrInsertComputed(). The callback also receives the key, which keeps lazy caches short: const upper = new Map(); console.log(upper.getOrInsertComputed("hello", (key) => key.toUpperCase())); // "HELLO" A realistic example: per-element state in a WeakMap The same methods exist on WeakMap, which is where they get quietly useful. Say you want to track state for DOM elements without leaking memory when they are removed: const state = new WeakMap(); function stateFor(element) { return state.getOrInsertComputed(element, () => ({ clicks: 0 })); } document.addEventListener("click", (event) => { const button = event.target.closest("button"); if (!button) return; const s = stateFor(button); s.clicks++; button.dataset.clicks = s.clicks; }); The first click creates the state object, every later click reuses it, and when the button leaves the DOM the entry can be garbage collected. In a Chrome test page, clicking button A three times and a <span> inside button B once left data-clicks="3" and data-clicks="1", with the callback running exactly twice. Can I use it? From MDN's compatibility data: | Runtime | Version | |---|---| | Chrome / Edge | 145 | | Firefox | 144 | | Safari | 26.2 | | Node.js | 26.0.0 | MDN marks it Baseline 2026, Newly available since February 2026. That means current browsers are fine, but anything older needs a fallback or a polyfill, since the methods simply do not exist there. Gotchas 1. Node 24 does not have it. On Node 24.11 (V8 13.6), typeof Map.prototype.getOrInsert is "undefined". If your code also runs on the server, check your Node version before you delete that helper. 2. An existing undefined counts as existing. new Map([["x", undefined]]).getOrInsert("x", 5) returns undefined and leaves it there. The check is "does the key exist", not "is the value truthy", which is exactly what the || trick gets wrong. 3. The callback's return value wins. If the callback itself calls map.set(key, ...), the method still stores what the callback returns. In a run, the map ended up holding "returned", not the value set inside the callback, with a size of 1. Better still, do not mutate the map from inside the callback. 4. WeakMap keys still must be objects. new WeakMap().getOrInsert("str", 1) throws TypeError: Invalid value used as weak map key. The new methods do not relax the old rules. 5. The error message blames the wrong thing. Pass a non-function to getOrInsertComputed() and Chrome 155 throws TypeError: Map.prototype.getOrInsertComputed is not a function. The method exists. It is your second argument that is not a function. Wrap-up The has, set, get dance was always three lookups wearing a trench coat. Find your getOrCreate helper, swap it for getOrInsert() or getOrInsertComputed(), and delete one more file from utils/. Sources - MDN: Map.prototype.getOrInsert() - MDN: Map.prototype.getOrInsertComputed() - MDN: WeakMap.prototype.getOrInsertComputed() - MDN browser-compat-data: Map - TC39: Upsert proposal - TC39: Finished proposals - ECMAScript spec: Map.prototype.getOrInsert This article was written with the help of AI and checked against the sources above.
他社サイトに置いたiframeの中でTurnstileが動くか試した 他社のサイトに script タグ 1 行で設置するウィジェットを作るとき、訪問者が bot でないかを Cloudflare Turnstile で確かめたくなります。ところが Turnstile は、widget を出すページのホスト名を事前に登録する必要があります。設置先が増えるたびに登録するのは現実的ではありません。 そこで、設置先のページには自前ドメインの iframe だけを置き、Turnstile はその iframe の中で出す形を試しました。 結論 設置先のページに置いた自前ドメインの iframe の中で、Chrome 156 と Safari 27 のどちらでも token が取れ、Siteverify は success: true でした Siteverify が返す hostname は iframe 側の自前ドメインでした。設置先のホスト名は登録しなくて済みます その代わり、hostname からはどのサイトに設置されたかが分かりません。設置先の判定は別に作る必要があります 設置先のページに同じ sitekey で直接出すと、エラー 110200 (Domain not authorized) になりました Playwright で動かしたブラウザでは、headless・headed・本体の Chrome のどれでも 600010 になり、token は取れませんでした。この計測は人が普段のブラウザで押して行っています 設置先ごとの key と Origin を確かめるだけの口は、curl で Origin を付けて送ると 200 で通りました。Turnstile の token も確かめる口は、偽装だけでは 403 でした iframe のページに frame-ancestors (key に登録したドメインだけ) と Sec-Fetch-Dest: iframe の確認を入れると、Chromium と WebKit のどちらでも、登録外のサイトに置いたときと直接開いたときは描画されませんでした Turnstile の解決サービスと、ブラウザ以外からヘッダーを偽装して iframe のページを取る経路は残ります ホスト名の登録の上限 Turnstile の widget には、動かすホスト名を登録します。公式のホスト名の管理のページ によると、上限は無料で 1 widget あたり 10 個、Enterprise で 200 個です。ワイルドカードは使えません (登録したホスト名のサブドメインには効きます)。 どのホスト名でも動かせる Any Hostname は Enterprise だけの機能です。 設置先が数十、数百と増えるウィジェットでは、設置先のページに直接 widget を出す形は無料では成り立ちません。iframe の中で出す形が使えるかは、公式のページには書かれていませんでした。 試した構成 自前ドメイン Y: Turnstile の widget にホスト名を登録したドメイン。この記事では widget.example.com と書きます。手元では /etc/hosts で 127.0.0.1 に向け、mkcert の証明書で https にしました 設置先 X: lvh.me。公開 DNS で 127.0.0.1 を返すドメインで、widget には登録していません。Y とは別のサイト (eTLD+1 が違う) になります sitekey は本物を使いました。テスト用の sitekey (1x000...) はホスト名を見ないので、この確認には使えません ページは 3 つです。 Y に直接 widget を出すページ。Y が登録済みで token が取れることの確認 X のページに Y の iframe を置き、iframe の中で widget を出すページ。これが本題です X のページに直接 widget を出すページ。対照です widget の結果 (token かエラー) は Y のサーバーに送り、サーバーが token ごとに Siteverify を呼んで、応答をそのまま記録しました。計測は 2026-10-24 です。 結果 普段使いの Chrome と Safari で、人がページを開いて押した結果です。 ブラウザ ページ token Siteverify の success hostname Chrome 156 1. Y に直接 取れた true Y Chrome 156 2. X の中の Y の iframe 取れた (2 回) true Y Chrome 156 3. X に直接 110200 - - Safari 27.0 1. Y に直接 取れた true Y Safari 27.0 2. X の中の Y の iframe 取れた true Y iframe の中で取れた token の Siteverify の応答は次のとおりです (Safari の回。ホスト名は置き換えてあります)。 {"action":"","cdata":"","challenge_ts":"2026-10-24T12:03:55.000Z","error-codes":[],"hostname":"widget.example.com","metadata":{"interactive":false},"success":true} metadata.interactive は、Chrome の iframe の 2 回目だけ true でした。チェックボックスを押した回です。他の回は押す前に通っています。 X に直接出したページでは、widget が再試行を続け、約 10 秒ごとに 110200 が届きました (Chrome で 9 回)。エラーコードの一覧 では 110200 は「Domain not authorized」です。 Safari の 3 番 (X に直接) は記録できていません。計測用のサーバーを、Chrome と Safari の両方で 1 番と 2 番が揃ったら止まるようにしていたため、Safari で 3 番を開く前に止まりました。110200 は Chrome と、後で書く Playwright の回で確認しています。 ここから、widget のホスト名の判定は iframe の中のページ (Y) で行われ、親のページ (X) は見ていないと読めます。iframe を cross-site に置いても、Chrome・Safari とも token の取得は妨げられませんでした。 Playwright では取れない 最初は Playwright で自動化しようとしました。Chromium の headless、本体の Chrome (channel: "chrome") の headed のどちらでも、1 番と 2 番ではチェックボックスが出て、押すと 600010 になりました。600 番台は一覧で「Generic challenge failure」です。 1 番 (Y に直接) でも同じなので、iframe のせいではなく、自動操作のブラウザが bot と判定されていると見ています。3 番は Playwright でも 110200 で、localhost に出しても 110200 でした。 Turnstile の挙動を自動テストで確かめたいときは、この点に注意が要ります。ホスト名の判定 (110200 かどうか) までは自動で見られますが、token の取得と Siteverify の中身は人が押して確かめることになります。 設置先の見分け方 Siteverify の hostname は常に Y になるので、この token がどのサイトから来たかは分かりません。設置先ごとに上限をかけたい場合などは、別の仕組みで見分ける必要があります。 設置のコードには設置先ごとの key (公開してよい値) を入れ、サーバーには key ごとに設置を許したドメインを持たせる形を考えます。ここからは、この形でどこまで守れるかを試した結果です。計測は 2026-10-24、Playwright 1.59.1 の Chromium 147 と WebKit 26.4 (どちらも headless) です。 key と Origin の確認だけでは、ブラウザ以外を止められない ブラウザの中の JavaScript は Origin ヘッダーを書き換えられません。一方、curl などブラウザ以外のプログラムは Origin も Referer も好きな値で送れます。key はページのソースに載るので、誰でも読めます。 検証用のサーバーに、key と Origin だけを確かめる口 (/session-origin-only) と、それに Turnstile の確認を足した口 (/session) を作り、curl で送りました。key site-a に登録したドメインは lvh.me です。 curl -sk -X POST widget.example.com \ -H 'content-type: application/json' -H 'Origin: lvh.me' \ -d '{"key":"site-a"}' 送ったもの 口 ステータス 応答 Origin なし /session 403 {"ok":false,"stage":"origin","origin":null} Origin を偽装 /session-origin-only 200 {"ok":true,"stage":"origin-only","origin":"lvh.me"} Origin を偽装、token なし /session 403 Siteverify の error-codes が ["missing-input-response"] Origin を偽装、token に forged-token /session 403 Siteverify の error-codes が ["invalid-input-response"] Origin を付けるだけで、key と Origin の確認は通りました。Turnstile の token も確かめる口では、偽装だけでは通れません。Siteverify は、token が無いと missing-input-response、でたらめな token だと invalid-input-response を返しました。Siteverify のページ によると token は 1 回しか検証できず、有効なのは発行から 300 秒です。 そうなると、正しい token を手に入れる道は次の 2 つです。 人がブラウザで iframe のページを開き、widget を通す Turnstile を解く有料の解決サービスに解かせる 2 はサーバーからは見分けられず、止められないと考えています。私の設計では、IP や端末ごとの上限と、設置先ごとの月の上限を置いて、使われても被害が上限で止まるようにします。この上限の効果は測っていません。 1 は、iframe のページを登録したサイトの中でしか開けないようにすれば、狭められます。それを次に試しました。 iframe のページを登録サイトの中に限る iframe のページ (/widget.html?key=site-a) を返すときに、次の 2 つを入れました。 Content-Security-Policy: frame-ancestors に、その key に登録したドメインだけを入れる。iframe を置けるページをブラウザが制限します。<meta> では効かないので、HTTP のヘッダーで返します Sec-Fetch-Dest が iframe でなければ 403 にする。直接開いたときは document が届くので、iframe の外では widget を出しません const allowed = sites[key]; // 例: { "site-a": ["lvh.me"] } const dest = req.headers["sec-fetch-dest"] ?? null; if (!allowed) { res.writeHead(404); res.end(); return; } if (dest !== "iframe") { res.writeHead(403); res.end("iframe の中でだけ開けます"); return; } res.writeHead(200, { "content-type": "text/html; charset=utf-8", "content-security-policy": frame-ancestors ${allowed.join(" ")}, }); 試したのは 3 通りです。 登録サイトの中: lvh.me に iframe を置く 登録外サイトの中: localhost に同じ iframe を置く。lvh.me とは別のサイトです 直接開く: widget.example.com をタブで開く Turnstile の本物の token は自動操作では取れない (600010) ので、iframe のページが描画されたかで判定しました。iframe のページには目印の要素を置き、中で Turnstile のテスト用 sitekey (1x000...、常に通る) の widget を出しています。 ブラウザ 開き方 サーバーに届いた Sec-Fetch-Dest 応答 iframe のページの描画 Chromium 147 登録サイトの中 iframe 200 描画された (目印あり、テスト用 widget の callback も呼ばれた) Chromium 147 登録外サイトの中 iframe 200 描画されない (frame の URL は chrome-error://chromewebdata/) Chromium 147 直接開く document 403 描画されない (本文は「iframe の中でだけ開けます」) WebKit 26.4 登録サイトの中 iframe 200 描画された (目印あり、テスト用 widget の callback も呼ばれた) WebKit 26.4 登録外サイトの中 iframe 200 描画されない (frame の中身が空) WebKit 26.4 直接開く document 403 描画されない (本文は「iframe の中でだけ開けます」) 登録外サイトの中に置いたときのコンソールは次のとおりです。 Chromium: Framing 'widget.example.com' violates the following Content Security Policy directive: "frame-ancestors lvh.me". The request has been blocked. WebKit: Refused to load widget.example.com because it does not appear in the frame-ancestors directive of the Content Security Policy. 登録外サイトの中でも、サーバーは 200 を返しています。frame-ancestors はブラウザが応答を受け取ってから表示を止める仕組みで、サーバーはどこに置かれたかを知らずに返します。サーバーには Sec-Fetch-Site: cross-site と Referer: localhost が届いていましたが、Referer は設置先の Referrer-Policy で削られることがあるので、判定には使いませんでした。 Sec-Fetch-Dest を送らないブラウザ MDN の互換性データ では、Sec-Fetch-Dest は Chrome 80、Firefox 90、Safari 16.4 からです。また MDN によると、このヘッダーは potentially trustworthy な URL (https など) へのリクエストにしか付きません。iframe のページは https で返す必要があります。 今回のコードは、ヘッダーが無いときも 403 にしています。Safari 16.4 より前などのブラウザでは widget を出せません。ヘッダーが無いときだけ通す書き方もできますが、その場合、古いブラウザでは直接開けてしまいます。今回の WebKit 26.4 は iframe と document を送ってきました。手元の Safari の実物では確かめていません。 frame-ancestors は互換性データで Chrome 40、Firefox 58、Safari 10 からです。 別の key への token の流用を防ぐ: cData key ごとに上限をかけるなら、ある key の iframe で取った token を、別の key の会話に使われるのも防ぎたくなります。Turnstile の widget には cData を渡せ、Siteverify の応答の cdata で返ってきます。widget の設定のページ によると、最大 255 文字で、英数字と _・- だけが使えます。 turnstile.render("#w", { sitekey, cData: "site-a", callback: (token) => { /* サーバーへ送る */ } }); const result = await siteverify(token); if (result.success !== true || result.cdata !== input.key) { /* 403 */ } 前半の計測の Siteverify の応答にも "cdata":"" の項目がありました (このときは cData を渡していません)。本物の sitekey の token は自動操作では取れないため、cData の照合は計測していません。 それでも残る経路 解決サービスに解かせる経路は、上の対策では止まりません ブラウザ以外のプログラムは、Sec-Fetch-Dest: iframe も付けられます。curl で付けて送ると、iframe のページが 200 で返りました (付けないと 403)。frame-ancestors はブラウザが守るものなので、ブラウザ以外には効きません。取ったページを解決サービスに渡せば、正しい key の cData が入った token が取れると考えられます frame-ancestors と Sec-Fetch-Dest で狭められるのは、人が普通のブラウザで登録外の場所から widget を通す経路までです。残りは上限で被害を抑えることになります。 測れていないこと X は http で試しました。実際の設置先は https が普通ですが、https のページには置いていません Firefox と iOS の Safari は試していません Safari の「サイト越えトラッキングを防ぐ」は既定のままです。設定を変えた場合は試していません iframe の行に親のページを記録しておらず、2 番の結果が X の中から来たことは、開いた手順でしか示せません。公開したコードでは記録を足してあります frame-ancestors と Sec-Fetch-Dest は Playwright の Chromium と WebKit で測りました。普段使いの Chrome と Safari では試していません cData の照合と、IP・端末ごと・設置先ごとの上限の効果は測っていません コード 検証用のサーバーとページ、手で押す版と Playwright 版、対策の計測版 (src/protect.ts)、計測の結果は blog-examples に置いてあります。Turnstile の widget と、自前ドメインを 127.0.0.1 に向ける設定と証明書があれば動きます。
Twitter Added id_str in 2010 Because JavaScript Can't Hold a Java long - Book: Kotlin and Java to TypeScript - Also by me: The TypeScript Library — the 5-book collection - My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools - Me: xgabriel.com | GitHub --- On October 18, 2010, Twitter told every developer on its API that tweet IDs were about to change shape. A new generator called Snowflake would produce 64-bit IDs, and TechCrunch's write-up of the notice put the date plainly: "on November 26, these IDs will go longer than 53 bits." The fix Twitter shipped was blunt. Starting October 22, every ID in a JSON response would come back twice, once as a number and once as a string. In the words of developer advocate Matt Harris, quoted in the same article: "To allow javascript and JSON parsers to read the IDs we need to include a string version of any ID." That string field is id_str, and it is still in the API. The current X developer docs tell v1.1 clients to "always use id_str", and v2 skipped the number entirely: IDs come back as strings by default. Sixteen years later, the same bug still ships. A Spring Boot or Ktor service returns a Long ID, a TypeScript client calls JSON.parse, and the last few digits change. No exception. No warning in the console. The client then sends the wrong ID back to the server, and you get a 404 on a record that exists, or a hit on a different record whose ID happens to be the rounded value. Why Twitter needed 64 bits in the first place Snowflake was built for Twitter's move from MySQL to Cassandra. The original README is direct about the motive: "there is no sequential id generation facility in Cassandra, nor should there be." The requirements were uncoordinated generation across machines, rough time ordering, and IDs that stay under 64 bits. The layout it settled on: - 41 bits of millisecond timestamp against a custom epoch ("gives us 69 years") - 10 bits of machine ID, for up to 1024 machines - 12 bits of sequence number, rolling over every 4096 IDs per machine That is 63 bits of payload, which fits a signed 64-bit integer: a Java long, a Kotlin Long, a Scala Long. The Java Language Specification gives long the range -9223372036854775808 to 9223372036854775807. Every JVM backend can hold a Snowflake ID without thinking about it. The trouble starts when the ID leaves the JVM. !A 64-bit integer bar being squeezed into a shorter 53-slot container, with the extra bits spilling off the end A JVM long has 64 bits to spend. A JavaScript number has 53 for the integer part. 53 bits of mantissa versus 64 bits of long A JavaScript number is an IEEE 754 double. MDN's page on Number.MAX_SAFE_INTEGER spells out the consequence: a double "only has 52 bits to represent the mantissa," plus one implicit leading bit, so it can only safely represent integers between -(2^53 - 1) and 2^53 - 1. That ceiling is 9007199254740991. Past it, a double still exists for every power of two, but not for every integer. Just above 2^53 the gap between neighbouring doubles is 2. Just above 2^60 it is 2^(60-52), which is 256. Modern Snowflake IDs live up there, so a parsed ID snaps to the nearest representable double, and two real IDs generated a moment apart can land on the same one. You can see it in four lines: const body = '{"id": 9007199254740993}'; const order = JSON.parse(body) as { id: number }; console.log(order.id); // 9007199254740992 console.log(Number.isSafeInteger(order.id)); // false MDN shows the same collapse from the arithmetic side: Number.MAX_SAFE_INTEGER + 1 === Number.MAX_SAFE_INTEGER + 2 evaluates to true. The X docs use 10765432100123456789, which is past even a Java long's ceiling, and show it printing back as 10765432100123458000. None of this breaks the JSON spec. RFC 8259, section 6 "allows implementations to set limits on the range and precision of numbers accepted," and names only the integers in [-(2^53)+1, (2^53)-1] as the range where implementations "will agree exactly on their numeric values." Jackson writing 9007199254740993 is valid JSON. JSON.parse rounding it is also valid. The contract between them is the bug. TypeScript does not help here. JSON.parse returns any, your as { id: number } is a promise you made to the compiler, and the value already satisfies number. The type system checks the shape, and the shape is fine. Why BigInt doesn't fix JSON.parse The natural reaction from a JVM developer is: JavaScript has arbitrary-precision integers now, so parse into BigInt. Two things get in the way. First, JSON.parse never produces a BigInt. Numbers become doubles during parsing, before your code sees anything. The MDN JSON.parse page is explicit that the reviver "is run after the value is parsed," so numbers "will have already been converted to JavaScript numbers, and may lose precision in the process." Writing BigInt(value) inside a classic two-argument reviver gives you an exact BigInt of the wrong number. Second, the trip back is closed by default. Per MDN's BigInt page, calling JSON.stringify() on any value containing a BigInt throws a TypeError unless you supply a toJSON or a replacer. So BigInt is a fine type to hold an ID once you have the exact digits. It cannot recover digits the parser already threw away. The digits have to survive the parse, which means either they arrive as a string, or the parser hands you the source text. !Two different ID cards on a conveyor belt merging into a single card as they pass through a funnel Above 2^53, distinct IDs can parse to the same JavaScript number. Fix it on the JVM side: make the ID a string in the contract The fix Twitter chose, and the one Discord documents for its own Snowflake IDs ("they are always returned as strings in the HTTP API"), is to put the ID on the wire as a JSON string. Keep Long in the database and in your domain model. Change only the serialized form. With Jackson, a per-field annotation does it: import com.fasterxml.jackson.annotation.JsonFormat; public record OrderDto( @JsonFormat(shape = JsonFormat.Shape.STRING) long id, long totalCents ) {} In Jackson 2, the number serializers return ToStringSerializer.instance when the shape is STRING, so id goes out as "9007199254740993" with the quotes. The annotation lives in jackson-annotations, which the Spring team notes kept its package unchanged when Spring Boot 4 moved to Jackson 3 under tools.jackson, so the same line works across that upgrade. If every Long in your API is an identifier, you can register the serializer by type instead. On Spring Boot 3 with Jackson 2: @Bean Jackson2ObjectMapperBuilderCustomizer longsAsStrings() { return b -> b .serializerByType(Long.class, ToStringSerializer.instance) .serializerByType(Long.TYPE, ToStringSerializer.instance); } Be careful with that one. It also quotes counts, cents and timestamps that never needed it. Jackson's blunter switch, JsonWriteFeature.WRITE_NUMBERS_AS_STRINGS (off by default), "forces all regular number values to be written as JSON Strings," doubles included. Per field is usually the honest scope. On Ktor with kotlinx.serialization, the library ships a serializer for exactly this case. The LongAsStringSerializer docs describe it as "intended to be used for interoperability with external clients (mainly JavaScript ones)": import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.LongAsStringSerializer @Serializable data class OrderDto( @Serializable(with = LongAsStringSerializer::class) val id: Long, val totalCents: Long, ) Then write it down. If you publish an OpenAPI spec, the ID is type: string, not type: integer, format: int64, so generated TypeScript clients get string too. Fix it in the TypeScript client On the client, type the ID as what it is on the wire: a string. A branded type keeps it from mixing with other strings: type OrderId = string & { readonly __brand: "OrderId" }; interface Order { id: OrderId; totalCents: number; } You compare it, put it in URLs and send it back. You almost never do arithmetic on an ID, so a string loses nothing. If you need ordering or math, convert with BigInt(order.id) at that moment, from exact digits. Sometimes you don't own the server and it still sends bare numbers. For that case, TC39 added source text access to JSON.parse; the proposal is at stage 4. For primitive values, the reviver gets a third context argument whose source property is "the original JSON string representing this value" (MDN). MDN's compatibility data lists it in Chrome 114, Firefox 135, Safari 18.4, Node.js 21, Deno 1.33 and Bun 1.1.43. type Ctx = { source?: string }; function idsAsStrings(key: string, value: unknown, ctx?: Ctx) { if (key !== "id" || typeof value !== "number") return value; if (ctx?.source === undefined) { throw new Error("JSON.parse source access not supported"); } return ctx.source; // the exact digits, before rounding } const order = JSON.parse(text, idsAsStrings) as Order; The reviver throws when the runtime doesn't pass context, because falling back to String(value) would hand you the rounded digits as a string, which is the original bug wearing a disguise. And you need the raw text to call it: Response.json() takes no reviver, so read the body with await res.text() and parse it yourself. The same proposal added JSON.rawJSON for the outbound direction. A replacer like typeof v === "bigint" ? JSON.rawJSON(v.toString()) : v lets JSON.stringify emit a BigInt as a bare JSON number without precision loss, when a server insists on numeric IDs in request bodies. If you own the server, the one-line @JsonFormat change is the whole fix, and it costs less to ship before your ID generator crosses 2^53 than after the first 404 on a record that exists. --- If this was useful This bug lives exactly on the seam between the JVM and TypeScript: a Long that is perfectly safe on one side and quietly lossy on the other. Kotlin and Java to TypeScript is the book I wrote for that seam, mapping what you already know from Java and Kotlin (variance, null safety, sealed hierarchies, coroutines) onto how TypeScript does the same jobs. It's part of The TypeScript Library, five books with a clear path through them. Books 1 and 2 are the core path. If you come from the JVM, book 3 stands in for book 1. If you ship TypeScript at work, book 5 is the production layer: - TypeScript Essentials — types, narrowing, modules, async, daily-driver tooling - The TypeScript Type System — generics, mapped/conditional types, infer, template literals, branded types - Kotlin and Java to TypeScript — JVM developers moving to TypeScript - PHP to TypeScript — PHP 8+ developers moving to TypeScript - TypeScript in Production — tsconfig, build tools, monorepos, library authoring, dual ESM/CJS, JSR All five books ship in ebook, paperback, and hardcover. 
While drafting this month's issue I checked alpha() against the browsers rather than the compat data. BCD said Chrome 151. 151 does not parse it; 152 does. wpt.fyi and chromestatus agreed. I filed it, and MDN fixed it today. browser-compat-data#30731
While drafting this month's issue I checked alpha() against the browsers rather than the compat data. BCD said Chrome 151. 151 does not parse it; 152 does. wpt.fyi and chromestatus agreed. I filed it, and MDN fixed it today. browser-compat-data#30731
Open source contribution #2 is in. Last month: fixed a typo on MDN’s JavaScript docs. This month: the CSS image() page — “spacial” → “spatial”. Design systems week, CSS docs fix. Fate. @devanddesignhq #BuildInPublic content#46116
No, but it's on the way content#45834