<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Evan Mercer</title>
    <description>The latest articles on DEV Community by Evan Mercer (@evanmercerdev).</description>
    <link>https://dev.to/evanmercerdev</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4071518%2F37f8473f-282e-49d6-9305-19276c894a00.png</url>
      <title>DEV Community: Evan Mercer</title>
      <link>https://dev.to/evanmercerdev</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/evanmercerdev"/>
    <language>en</language>
    <item>
      <title>Handle HTML error pages before parsing a video metadata response</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Tue, 13 Oct 2026 14:39:45 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/handle-html-error-pages-before-parsing-a-video-metadata-response-1n3n</link>
      <guid>https://dev.to/evanmercerdev/handle-html-error-pages-before-parsing-a-video-metadata-response-1n3n</guid>
      <description>&lt;p&gt;A download interface can show &lt;code&gt;Unexpected token '&amp;lt;'&lt;/code&gt; even when the user's post link is valid. The metadata endpoint may have returned an HTML login page, an upstream error page, or a proxy response instead of JSON. Validate the HTTP response contract before parsing its body so users get an actionable failure and developers keep the actual cause.&lt;/p&gt;

&lt;p&gt;I maintain Twitter Viewer and its &lt;a href="https://twitterviewer.net/twitter-video-downloader" rel="noopener noreferrer"&gt;twitter video downloader&lt;/a&gt;. This article uses a fictional metadata endpoint and a small executable response parser. It describes an interface boundary, not Twitter Viewer's private implementation.&lt;/p&gt;

&lt;h2&gt;
  
  
  Check status before interpreting a successful result
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;fetch&lt;/code&gt; resolves for HTTP errors such as 404 and 500. A resolved promise only means a response arrived. Code that immediately calls &lt;code&gt;response.json()&lt;/code&gt; can hide an upstream 502 behind a JSON syntax error if the gateway generated an HTML page.&lt;/p&gt;

&lt;p&gt;Give each kind of failure a stable code. A non-success status is different from a successful response with an unexpected media type, and both differ from a JSON object with missing fields.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;readMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;fail&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;assign&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HTTP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;`Metadata request returned &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;split&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TYPE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Metadata endpoint did not return application/json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JSON&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Metadata endpoint returned invalid JSON&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;object&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt;
      &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;postId&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;fail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SCHEMA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Metadata response is missing postId or variants&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save this as &lt;code&gt;metadata.mjs&lt;/code&gt;. The schema check is intentionally small: it establishes that the outer object can be consumed by a caller. A real application should validate each variant's properties before rendering a link or initiating a transfer. A string with the right property name is not automatically a trusted fetch destination.&lt;/p&gt;

&lt;p&gt;This contract accepts &lt;code&gt;application/json&lt;/code&gt; with optional parameters, including &lt;code&gt;application/json; charset=utf-8&lt;/code&gt;. It intentionally rejects vendor media types. If your API uses a vendor JSON type, add it explicitly to the contract and its tests instead of accepting any header that happens to contain the substring &lt;code&gt;json&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Exercise failures without making a network request
&lt;/h2&gt;

&lt;p&gt;Modern Node.js includes the &lt;code&gt;Response&lt;/code&gt; class. A test can construct the exact bad response a proxy might return:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;readMetadata&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./metadata.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;json&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;text&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/json; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accepts the expected envelope&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deepEqual&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;readMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"postId":"123","variants":[]}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;postId&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;variants&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;classifies status, content, and shape separately&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;async &lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;h1&amp;gt;Bad gateway&amp;lt;/h1&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;502&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HTTP&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;&amp;lt;html&amp;gt;Sign in&amp;lt;/html&amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-type&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/html&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="p"&gt;})),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TYPE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;JSON&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;rejects&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;readMetadata&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{"variants":[]}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;code&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;SCHEMA&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Save that as &lt;code&gt;metadata.test.mjs&lt;/code&gt; and run &lt;code&gt;node --test metadata.test.mjs&lt;/code&gt;. No API credentials, social account, or live video are needed. These are response-contract tests, so a platform outage cannot make the test results ambiguous.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep diagnostics out of the visible error
&lt;/h2&gt;

&lt;p&gt;For the user, an HTTP failure might warrant “Video lookup is temporarily unavailable; retry shortly.” An HTML response might warrant the same recovery advice while retaining a different internal code. Log the status, response type, request ID, and endpoint name where appropriate. Avoid pasting an entire unexpected HTML body into an error toast: it may contain account details, tokens, or internal infrastructure text.&lt;/p&gt;

&lt;p&gt;Once metadata is valid, downloading remains a separate operation with its own response contract. Receiving a list of variants does not prove a file reached the user's device. Native public videos, externally embedded players, and inaccessible posts also require distinct source handling; a generic JSON parsing message should never be the explanation for all of them.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Validate X post URLs before requesting video metadata in JavaScript</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Mon, 12 Oct 2026 23:09:17 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/validate-x-post-urls-before-requesting-video-metadata-in-javascript-a32</link>
      <guid>https://dev.to/evanmercerdev/validate-x-post-urls-before-requesting-video-metadata-in-javascript-a32</guid>
      <description>&lt;p&gt;A pasted social URL is untrusted input. Before a backend tries to retrieve media, it should extract a small, well-defined identifier and discard everything else. This article implements that boundary for public X post URLs in JavaScript. I maintain Twitter Viewer; the parser below is an example of input handling, not a claim that these snippets reproduce its backend.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse the URL before matching the path
&lt;/h2&gt;

&lt;p&gt;Matching a full URL with a loose regular expression tends to accept misleading hosts. A string can contain &lt;code&gt;x.com&lt;/code&gt; in a username, query parameter, or an attacker-controlled subdomain. Let the URL parser separate those components first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;parsePostUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Enter an absolute post URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;HTTPS required&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;username&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;password&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Credentials rejected&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;port&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;443&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unexpected port&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;host&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;toLowerCase&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;allowed&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;x.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;www.x.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;twitter.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;www.twitter.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;allowed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;host&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Unsupported host&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;match&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/([&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9_&lt;/span&gt;&lt;span class="se"&gt;]{1,15})\/&lt;/span&gt;&lt;span class="sr"&gt;status&lt;/span&gt;&lt;span class="se"&gt;\/([&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]{1,20})(?:\/(?:&lt;/span&gt;&lt;span class="sr"&gt;video|photo&lt;/span&gt;&lt;span class="se"&gt;)\/[&lt;/span&gt;&lt;span class="sr"&gt;1-9&lt;/span&gt;&lt;span class="se"&gt;][&lt;/span&gt;&lt;span class="sr"&gt;0-9&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;*&lt;/span&gt;&lt;span class="se"&gt;)?\/?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Expected a post URL&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;author&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;id&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
    &lt;span class="na"&gt;canonicalUrl&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://x.com/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;/status/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This intentionally accepts a narrow set of common web links. It does not promise support for every historical Twitter route. Mobile hosts, share wrappers, and links without a handle can be added as explicit cases after they have tests. A rejected unfamiliar route should produce a useful message rather than silently falling through to arbitrary fetching.&lt;/p&gt;

&lt;p&gt;Keep the post ID as a string. Large decimal identifiers can exceed JavaScript's safe integer range; converting one to &lt;code&gt;Number&lt;/code&gt; may change its value before the request even reaches the server. The handle is useful for a human-readable link but should not be treated as an immutable account identity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Test deceptive hosts as well as normal links
&lt;/h2&gt;

&lt;p&gt;Save the parser as &lt;code&gt;post-url.mjs&lt;/code&gt;, then create &lt;code&gt;post-url.test.mjs&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;test&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:test&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;node:assert/strict&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;parsePostUrl&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;./post-url.mjs&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;drops share parameters and keeps the ID exact&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parsePostUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://twitter.com/example/status/18446744073709551615?s=20&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;18446744073709551615&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;canonicalUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://x.com/example/status/18446744073709551615&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;rejects URLs that merely contain a trusted hostname&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://x.com.example.org/example/status/123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://x.com@other.example/example/status/123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://other.example/?next=https://x.com/example/status/123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;http://x.com/example/status/123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;throws&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nf"&gt;parsePostUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;

&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;accepts a media-view suffix but removes it&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;equal&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;parsePostUrl&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://x.com/example/status/123/video/1&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nx"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Run &lt;code&gt;node --test post-url.test.mjs&lt;/code&gt;. These tests do not need an X account or network access. They describe the input contract, so upstream changes cannot turn a basic parser test into an intermittent integration failure.&lt;/p&gt;

&lt;h2&gt;
  
  
  A valid URL does not imply downloadable media
&lt;/h2&gt;

&lt;p&gt;After parsing, there are still independent outcomes: a deleted post, a private account, a post with no native video, an upstream timeout, and a successful lookup with several variants. Keep these separate in the response model. Returning “invalid URL” for every outcome teaches users to keep editing a perfectly valid link.&lt;/p&gt;

&lt;p&gt;For a manual workflow, my &lt;a href="https://twitterviewer.net/twitter-video-downloader" rel="noopener noreferrer"&gt;twitter video downloader&lt;/a&gt; accepts public tweet links and offers available MP4 variants. A third-party embedded player is a different media source; the fact that it appears inside a post does not make it a native X video. Saving a file also does not grant permission to republish it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Revalidate the boundary on the server
&lt;/h2&gt;

&lt;p&gt;Client validation improves feedback, but a caller can skip the client. Apply the same host and path policy on the server. Prefer passing the extracted ID into a constrained lookup rather than fetching the original pasted URL. If a backend follows redirects or later fetches an upstream media URL, that fetch needs its own destination and redirect policy; validating the initial social URL does not validate a later destination.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>security</category>
    </item>
    <item>
      <title>Parse a TikTok Profile URL Without Keeping Tracking Parameters</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Mon, 05 Oct 2026 23:56:25 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/parse-a-tiktok-profile-url-without-keeping-tracking-parameters-21ce</link>
      <guid>https://dev.to/evanmercerdev/parse-a-tiktok-profile-url-without-keeping-tracking-parameters-21ce</guid>
      <description>&lt;p&gt;People paste profile links from chats, browsers, and share sheets. Those links can carry query parameters that are irrelevant to identifying the account. A small parser should extract a handle from the profile path, reject unrelated hosts, and leave the original URL out of the lookup key.&lt;/p&gt;

&lt;p&gt;I built &lt;a href="https://tikstories.com/" rel="noopener noreferrer"&gt;TikStories&lt;/a&gt; to accept a public TikTok handle or profile URL and show currently active public Stories. The example below is a standalone parsing exercise, not a copy of the site's internal code. It deliberately handles ordinary profile URLs rather than every redirect or link format TikTok may issue.&lt;/p&gt;

&lt;h2&gt;
  
  
  Parse the host and path
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;profileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^@/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;[&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9._&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;handle&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;handle&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URL&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;catch&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;protocol&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https:&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;tiktok.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;www.tiktok.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;m.tiktok.com&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;includes&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;hostname&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\/&lt;/span&gt;&lt;span class="sr"&gt;@&lt;/span&gt;&lt;span class="se"&gt;([&lt;/span&gt;&lt;span class="sr"&gt;A-Za-z0-9._&lt;/span&gt;&lt;span class="se"&gt;]&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)\/?&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pathname&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The hostname check is exact. A string like &lt;code&gt;tiktok.com.example.org&lt;/code&gt; does not pass merely because it contains &lt;code&gt;tiktok.com&lt;/code&gt;. The path pattern also avoids treating a video URL or unrelated page as an account profile. &lt;code&gt;new URL()&lt;/code&gt; separates the query string, so tracking parameters never enter the returned handle.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try the cases that matter
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;@example.creator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;example.creator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.tiktok.com/@example.creator?lang=en&amp;amp;share_id=123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;example.creator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://m.tiktok.com/@example.creator/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;example.creator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://tiktok.com.example.org/@example.creator&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://www.tiktok.com/@example.creator/video/123&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
  &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;not a handle&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;span class="p"&gt;];&lt;/span&gt;

&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;cases&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;profileHandle&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;actual&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; !== &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;expected&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This approach does not resolve shortened links or prove that a handle exists. After parsing, the application still needs to look up the public account and handle empty results honestly. A private profile cannot be made public by changing the URL. If a product accepts more URL forms, add them as explicit cases with tests rather than weakening the host check.&lt;/p&gt;

</description>
      <category>javascript</category>
      <category>webdev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Designing the Empty State When the Thing Users Want Usually Isn't There</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Tue, 29 Sep 2026 02:30:27 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/designing-the-empty-state-when-the-thing-users-want-usually-isnt-there-314a</link>
      <guid>https://dev.to/evanmercerdev/designing-the-empty-state-when-the-thing-users-want-usually-isnt-there-314a</guid>
      <description>&lt;p&gt;Some features are empty most of the time by nature: a "live now" section, a sale banner, stories that expire after 24 hours. If the empty state is a blank area with "Nothing here", users read it as broken and leave. The fix is to treat "nothing right now" as the normal case, say so plainly, tell people when to come back if you know, and show the closest useful thing instead. Below is the pattern I use, with a small, framework-free implementation you can adapt.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three kinds of "empty"
&lt;/h2&gt;

&lt;p&gt;Lumping these together is the root of most bad empty states:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Nothing yet, and that's expected.&lt;/strong&gt; No live item right now; there will be later.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing, because of a limit.&lt;/strong&gt; The content exists but can't be shown (private, region-locked, needs permission).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Nothing, because something failed.&lt;/strong&gt; Network error, timeout, bad input.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each needs different wording and a different next step. Case 1 needs a fallback, case 2 needs an honest explanation, case 3 needs a retry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Model it explicitly
&lt;/h2&gt;

&lt;p&gt;Don't let the UI infer the state from &lt;code&gt;items.length === 0&lt;/code&gt;. Return a status alongside the data:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// status: "live" | "none" | "restricted" | "error"&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;viewState&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;message&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;restricted&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;restricted&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liveItems&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;none&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;fallback&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;recentItems&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;status&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;live&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;result&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;liveItems&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Show time remaining, not just existence
&lt;/h2&gt;

&lt;p&gt;When live items expire, the most useful single fact is how long each one has left. Round down to whole hours above one hour and show minutes below it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;timeLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;expiresAt&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;now&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;expired&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;mins&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ms&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mins&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;mins&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; min left`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;floor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;mins&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt; h left`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;timeLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;3600&lt;/span&gt;&lt;span class="nx"&gt;e3&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;e3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt; &lt;span class="c1"&gt;// "5 h left"&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;timeLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="nx"&gt;e3&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;             &lt;span class="c1"&gt;// "42 min left"&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;timeLeft&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;                  &lt;span class="c1"&gt;// "expired"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Compute it on the client from an absolute timestamp, so a tab left open for an hour doesn't keep showing a stale "3 h left". Refresh the label every minute.&lt;/p&gt;

&lt;h2&gt;
  
  
  Write the "none" state as information
&lt;/h2&gt;

&lt;p&gt;Bad: &lt;em&gt;No results.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Better: &lt;em&gt;No story is live right now. Stories last 24 hours, so check back later. Here are the most recent videos in the meantime.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The better version says what's missing, that this is normal, why, and what's shown instead. Then render the fallback directly underneath, without making people click a tab.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep "restricted" and "error" honest
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restricted:&lt;/strong&gt; say what the limit is and stop. "This account is private, so its posts aren't public." Don't hint that a workaround exists.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Error:&lt;/strong&gt; say it's a problem on your side or the network's, keep the user's input in the box, and offer a retry button.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A real example
&lt;/h2&gt;

&lt;p&gt;I use this pattern in &lt;a href="https://tikstories.com/" rel="noopener noreferrer"&gt;TikStories&lt;/a&gt;, a free viewer for public TikTok profiles. Most public accounts have no active story at any given moment, so the "none" state is the common one: the page says no story is live, and the Videos tab already holds the account's recent posts, so there's still something to look at. When stories are live, each tile carries the hours left before it expires. Private accounts get a plain "this account is private" message, with no suggestion that anything can get around it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Checklist
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;[ ] The API distinguishes none / restricted / error; the UI doesn't guess from an empty array.&lt;/li&gt;
&lt;li&gt;[ ] The "none" message says it's normal and, if possible, when to come back.&lt;/li&gt;
&lt;li&gt;[ ] A useful fallback renders in the same view.&lt;/li&gt;
&lt;li&gt;[ ] Expiring items show time remaining computed from an absolute timestamp.&lt;/li&gt;
&lt;li&gt;[ ] Restricted states don't imply a workaround.&lt;/li&gt;
&lt;li&gt;[ ] Errors keep the user's input and offer a retry.&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>ux</category>
      <category>webdev</category>
      <category>javascript</category>
      <category>design</category>
    </item>
    <item>
      <title>What a no-login viewer can and can't hide from the platform</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Tue, 22 Sep 2026 23:53:43 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/what-a-no-login-viewer-can-and-cant-hide-from-the-platform-42ja</link>
      <guid>https://dev.to/evanmercerdev/what-a-no-login-viewer-can-and-cant-hide-from-the-platform-42ja</guid>
      <description>&lt;p&gt;"Anonymous" is the word everyone reaches for when describing a tool that shows public social media content without an account. I use it too, for &lt;a href="https://tikstories.com/" rel="noopener noreferrer"&gt;TikStories&lt;/a&gt;, the small TikTok story viewer I maintain. But the word covers two separate requests with different privacy properties, and it's worth pulling them apart, because the honest answer to "can TikTok see me?" is "partly, and here is which part."&lt;/p&gt;

&lt;h2&gt;
  
  
  Two requests, not one
&lt;/h2&gt;

&lt;p&gt;When you paste a username into a viewer like this, two different things happen:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;The lookup.&lt;/strong&gt; Something has to ask TikTok "what stories and videos does this public account have right now?" and get back a list of items with their URLs and timestamps.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The playback.&lt;/strong&gt; Once you pick a story, the actual video bytes have to travel from a server to your browser.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Each of these requests has its own sender, and the privacy question is really "who is the sender, and what do they carry?"&lt;/p&gt;

&lt;h2&gt;
  
  
  The lookup: a server with nothing attached
&lt;/h2&gt;

&lt;p&gt;In TikStories the lookup runs server-side. The server asks for the public account's data carrying no cookies and no session — no TikTok account of mine, and obviously none of yours, because you never gave the tool one.&lt;/p&gt;

&lt;p&gt;What that buys you:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;TikTok doesn't receive your login cookie for that request, because it isn't coming from your browser.&lt;/li&gt;
&lt;li&gt;Nothing about the lookup is tied to a TikTok account, so it can't land in a "recently viewed" list or a story-viewers list on the account owner's side.&lt;/li&gt;
&lt;li&gt;Your own TikTok recommendations aren't nudged by the lookup, since no account is attached to learn from.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What it doesn't buy you: the tool's server still made the request. Whatever TikTok logs about server traffic, it logs about that server, not about nobody.&lt;/p&gt;

&lt;h2&gt;
  
  
  The playback: direct from TikTok's CDN
&lt;/h2&gt;

&lt;p&gt;This is the part people tend to assume is also hidden, and it isn't. Once a story or video is on screen, it streams straight from TikTok's own CDN into your browser, exactly as it would on any public TikTok page.&lt;/p&gt;

&lt;p&gt;That means the CDN request comes from your browser, so it carries your IP address and ordinary browser headers, like any image or video you load from any website. It does &lt;em&gt;not&lt;/em&gt; carry a TikTok login, because the media URL is a plain CDN link, not a request made from inside your logged-in TikTok session.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why not proxy the video too?
&lt;/h2&gt;

&lt;p&gt;The obvious "more private" design is to route the video bytes through the tool's own server, so the CDN only ever sees the server. TikStories doesn't, and the trade-offs are worth spelling out:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Cost and speed.&lt;/strong&gt; Video is the heavy part. Proxying it would mean every byte of every story passing through the server twice. For a free tool with no query limit, that trades a small, well-understood privacy gain for a much slower, much more fragile service.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It moves the trust, it doesn't remove it.&lt;/strong&gt; A proxy hides your IP from TikTok's CDN by showing it to the proxy operator instead. For most people, "a CDN that serves half the internet's short video sees a request" is not a worse outcome than "a stranger's side project sees every video you watch."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It invites the wrong expectations.&lt;/strong&gt; A proxy makes a tool look like a privacy product. This one isn't. It's a way to read something public without being forced to sign in first.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  A way to explain it to users
&lt;/h2&gt;

&lt;p&gt;The one-sentence version I've settled on:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The lookup carries no account, yours or mine; the video plays from TikTok's CDN like any public page, so it sees an ordinary request from your browser, without a login attached.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;It's less catchy than "100% anonymous," but it holds up if someone checks the network tab, which is the test any privacy claim should pass.&lt;/p&gt;

&lt;h2&gt;
  
  
  The limits that follow from this
&lt;/h2&gt;

&lt;p&gt;Because the whole design is "read what's already public, without a session," a few things are simply out of reach, and I'd rather say so than have people discover it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Private accounts&lt;/strong&gt; can't be opened. Their stories are only served to approved followers; there's no public request that returns them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expired stories&lt;/strong&gt; are gone. Stories disappear 24 hours after posting and nothing is archived, which is also why each tile shows the hours left.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Your own story's viewer list&lt;/strong&gt; only exists inside the TikTok app, tied to your account.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you're building anything in this category, whether for TikTok, X, Instagram or anything else with a login wall in front of public content, I'd suggest writing down your own version of those two requests before writing the word "anonymous" on the landing page. It makes the copy more honest and the support inbox quieter.&lt;/p&gt;

</description>
      <category>privacy</category>
      <category>webdev</category>
      <category>architecture</category>
      <category>showdev</category>
    </item>
    <item>
      <title>Handing several files to a browser user: zip or not</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Mon, 21 Sep 2026 17:08:25 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/handing-several-files-to-a-browser-user-zip-or-not-1d76</link>
      <guid>https://dev.to/evanmercerdev/handing-several-files-to-a-browser-user-zip-or-not-1d76</guid>
      <description>&lt;p&gt;If your web tool produces exactly one file, you are fine. &lt;code&gt;Content-Disposition: attachment&lt;/code&gt;, one click, done.&lt;/p&gt;

&lt;p&gt;The moment it produces &lt;em&gt;several&lt;/em&gt; files, you have a decision to make, and both options are worse than you expect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option A: trigger N downloads
&lt;/h2&gt;

&lt;p&gt;The obvious approach is to create N anchors and click them in a loop.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt; &lt;span class="k"&gt;of&lt;/span&gt; &lt;span class="nx"&gt;files&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;a&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;document&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createElement&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;a&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;href&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;download&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;file&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="nx"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This works in a demo with two files and degrades badly after that.&lt;/p&gt;

&lt;p&gt;Chrome treats a burst of programmatic downloads from one gesture as suspicious. The first one goes through; somewhere after that the user gets an "allow multiple downloads?" prompt, and if they miss it or dismiss it, the rest vanish silently. Your code has no idea. There is no error, no rejected promise, nothing to catch — the downloads just do not happen.&lt;/p&gt;

&lt;p&gt;Safari on iOS is stricter still. A download not tied closely to a real user gesture tends to be dropped, and looping over anchors puts you well outside "closely".&lt;/p&gt;

&lt;p&gt;You can paper over this with delays between clicks, and people do. It is a heuristic fighting a heuristic, and it breaks whenever the browser retunes the rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  Option B: zip them server-side
&lt;/h2&gt;

&lt;p&gt;One request, one file, one download. No multi-download prompt, no gesture heuristics, works the same on desktop and mobile.&lt;/p&gt;

&lt;p&gt;The cost lands on the user instead. They now have an archive, and on a phone that means finding it, opening it, and extracting it — which on iOS is a real detour, and for someone who only wanted one of the images inside it is a bad trade.&lt;/p&gt;

&lt;p&gt;There is a server cost too: you need every file in hand before you can send the first byte, or you need to stream the zip as you go, which rules out knowing the total size up front.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the line actually falls
&lt;/h2&gt;

&lt;p&gt;After shipping both, the rule I ended up with is not about file count. It is about whether the files are &lt;em&gt;one thing&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Images from a multi-photo post: zip.&lt;/strong&gt; Four photos posted together are a set. Nobody wants the third one alone, the names are meaningless individually, and they are small enough that the archive is not a burden. One download, one folder, order preserved.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Video clips from a post with several videos: separate.&lt;/strong&gt; A post with three clips is usually three separate things, they are large, and most people want one of them. Zipping forces someone who wanted a 4 MB clip to pull down 60 MB and unpack it to get at it.&lt;/p&gt;

&lt;p&gt;That is the actual split in &lt;a href="https://xvideodownload.app/" rel="noopener noreferrer"&gt;X Video Downloader&lt;/a&gt;, the small tool I maintain for saving public media from X: image carousels come back as a single archive, multiple video clips come back as one file each. It looks inconsistent in a feature list. In use it is the difference between "that did what I meant" and "why did it do that".&lt;/p&gt;

&lt;h2&gt;
  
  
  The bit that is easy to miss
&lt;/h2&gt;

&lt;p&gt;Whichever you pick, decide what happens when the user only wants one item — and make that path exist. For a zip, that means the page still needs a per-file link next to the bundle. It is the cheapest thing in this whole post to build, and the one people reach for most.&lt;/p&gt;

&lt;p&gt;The general version: "several files" is not one problem. It is two, and the shape of the files tells you which one you are looking at.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>discuss</category>
    </item>
    <item>
      <title>Watching TikTok Stories Without the App: What "Anonymous" Actually Means</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Sat, 19 Sep 2026 11:40:25 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/watching-tiktok-stories-without-the-app-what-anonymous-actually-means-39i</link>
      <guid>https://dev.to/evanmercerdev/watching-tiktok-stories-without-the-app-what-anonymous-actually-means-39i</guid>
      <description>&lt;p&gt;Someone sends you a TikTok link. You don't have the app installed — or you deleted it a while back — and TikTok answers with a full-screen wall asking you to open it in-app or sign in. The post itself is public. Nothing about it requires an account. The wall is just friction between you and something anyone could already see.&lt;/p&gt;

&lt;p&gt;I ran into this often enough that I built a small tool for it: &lt;a href="https://tikstories.com/" rel="noopener noreferrer"&gt;TikStories&lt;/a&gt;. Paste a public username or profile link, and it shows you the account's active stories — each one tagged with how many hours are left before it expires — plus their recent videos when nothing is currently live.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why "recent videos" matters as much as the story viewer
&lt;/h2&gt;

&lt;p&gt;Most story-specific viewers I tried before building this have the same failure mode: check an account, no story is running right now (the normal case, not an edge case), and you get an empty page that looks broken. That's a bad answer to the question someone actually came with, which is usually closer to "what has this account been posting" than "show me literally only the disappearing content."&lt;/p&gt;

&lt;p&gt;So when there's no active story, TikStories falls back to the account's recent video grid instead of a blank screen. The page still answers something.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "anonymous" means here, concretely
&lt;/h2&gt;

&lt;p&gt;The lookup is a plain server-to-server request — no login, no cookies, no session tied to a TikTok account of mine or yours. Once a story or video is actually on screen, playback streams straight from TikTok's own CDN, same as any public page would load it. I can tell you exactly what the lookup itself sends; I can't audit what TikTok's servers do with any given request, and I'm not going to claim otherwise.&lt;/p&gt;

&lt;p&gt;It also only works on public accounts, by construction — a private account's stories are served to its approved followers and nobody else, so there's nothing for a tool like this to fetch. That's not a limitation to work around, it's the same boundary the platform itself enforces.&lt;/p&gt;

&lt;p&gt;Free, no account needed on either end, no query cap. Built and maintained by me as a side project, same shape as a couple of other small tools I run for reading public content on other platforms without a login wall in the way.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>privacy</category>
      <category>showdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Show DEV: X Video Downloader — a single-purpose video/GIF/image saver for X</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Wed, 16 Sep 2026 14:21:36 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/show-dev-x-video-downloader-a-single-purpose-videogifimage-saver-for-x-4eih</link>
      <guid>https://dev.to/evanmercerdev/show-dev-x-video-downloader-a-single-purpose-videogifimage-saver-for-x-4eih</guid>
      <description>&lt;p&gt;I keep a small folder of single-purpose web tools I've built for myself over the years, and most of them never see the light of day. This one did, because enough people kept asking me the same question that I figured it was worth cleaning up and sharing.&lt;/p&gt;

&lt;h2&gt;
  
  
  The problem
&lt;/h2&gt;

&lt;p&gt;Someone posts a video, a GIF, or a set of photos on X (Twitter), and you want to keep a copy — for a write-up, a backup, or just because the post might get deleted. X doesn't give you a "download" button for any of this. Your options end up being a browser extension you have to trust with a lot of permissions, a sketchy site full of redirect ads, or digging through devtools for the raw media URL by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I built
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://xvideodownload.app/" rel="noopener noreferrer"&gt;X Video Downloader&lt;/a&gt; is a single page that does one thing: you paste the link to a public X post, and it surfaces the downloadable video, GIF, or image carousel from that post as an MP4 (HD or SD, depending on what the post itself has available) or the original images.&lt;/p&gt;

&lt;p&gt;A few constraints I kept on purpose:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;No login, no account.&lt;/strong&gt; Nothing to sign up for, nothing of yours stored.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Public posts only.&lt;/strong&gt; It can't touch anything behind a login wall, and it isn't meant to — that's not a bug to report, it's the boundary.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;One job.&lt;/strong&gt; No audio extraction, no Space recordings, no bulk/batch mode. If you need those, I have a separate, more full-featured tool for that; this one is intentionally the stripped-down version.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why a separate tool instead of a feature flag
&lt;/h2&gt;

&lt;p&gt;I already maintain a more full-featured X downloader that also handles audio conversion and Space recordings. I built this one anyway because most visits to that tool are people who just want a video or a GIF, fast, without wading through options they don't need. Splitting it into its own single-purpose page made the common case faster and the page itself simpler to maintain.&lt;/p&gt;

&lt;h2&gt;
  
  
  What's next
&lt;/h2&gt;

&lt;p&gt;Mobile-friendliness and load speed are the two things I keep iterating on, since most traffic comes from someone on their phone trying to save a clip they just watched. If you try it and hit something that doesn't work on a specific post, I'd genuinely like to hear about it.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Saving X (Twitter) Space Recordings as MP3 Files</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Sun, 06 Sep 2026 11:09:19 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/saving-x-twitter-space-recordings-as-mp3-files-47ld</link>
      <guid>https://dev.to/evanmercerdev/saving-x-twitter-space-recordings-as-mp3-files-47ld</guid>
      <description>&lt;p&gt;X Spaces are ephemeral by design. Once the host ends a Space, the audio sticks around for a while as a recording inside the X app, but there's no first-party way to pull that audio out and keep it as a normal file. If you want to relisten offline, drop it into a podcast app, or archive a conversation you were part of, you're stuck replaying it inside the app every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this bugged me
&lt;/h2&gt;

&lt;p&gt;I run a couple of small tools around X (Twitter) content — mainly a video downloader for regular tweets — and kept getting the same request from people using it: "does this work for Spaces too?" It didn't, because a Space recording isn't a video, it's a separate audio stream with its own delivery mechanism.&lt;/p&gt;

&lt;p&gt;So I built a dedicated &lt;a href="https://twittervideodownload.pro/twitter-space-downloader" rel="noopener noreferrer"&gt;Space downloader&lt;/a&gt; that handles just that case: paste the link to a &lt;em&gt;recorded&lt;/em&gt; (finished) Space, and it converts the audio into a downloadable MP3.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does and doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Works on Spaces that have already ended and are still available as a recording.&lt;/li&gt;
&lt;li&gt;Does not work on a Space that's currently live — there's no finished audio to fetch yet.&lt;/li&gt;
&lt;li&gt;Output is a plain MP3, so it plays anywhere: podcast apps, a phone's default player, whatever you use for audio.&lt;/li&gt;
&lt;li&gt;No account or app install required on either end.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The actual use cases I see
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Founders/teams doing recurring AMAs who want an audio archive outside of X.&lt;/li&gt;
&lt;li&gt;Listeners who caught a good Space live and want to relisten during a commute without streaming it.&lt;/li&gt;
&lt;li&gt;Journalists or researchers who need a durable, offline copy of a public conversation for reference.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's free to use, and I built it as a companion to the video downloader rather than folding audio extraction into an already-busy tool. If you've hit the same "I want this Space as a normal file" wall, it's here: &lt;a href="https://twittervideodownload.pro/twitter-space-downloader" rel="noopener noreferrer"&gt;https://twittervideodownload.pro/twitter-space-downloader&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Happy to answer questions about format support or edge cases in the comments.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>productivity</category>
    </item>
    <item>
      <title>Show DEV: read public X profiles, threads and media without an account</title>
      <dc:creator>Evan Mercer</dc:creator>
      <pubDate>Thu, 13 Aug 2026 15:00:43 +0000</pubDate>
      <link>https://dev.to/evanmercerdev/show-dev-read-public-x-profiles-threads-and-media-without-an-account-6di</link>
      <guid>https://dev.to/evanmercerdev/show-dev-read-public-x-profiles-threads-and-media-without-an-account-6di</guid>
      <description>&lt;p&gt;Someone drops an X link in a group chat. You tap it, and instead of the post you get a login&lt;br&gt;
screen. The post is public. You just can't read it.&lt;/p&gt;

&lt;p&gt;That happened to me often enough that I built a way around the reading part, and then put it&lt;br&gt;
online for anyone else who wanted it: &lt;a href="https://twitterviewer.net/" rel="noopener noreferrer"&gt;twitterviewer.net&lt;/a&gt;. I'm the&lt;br&gt;
person who makes and maintains it, so treat this as a maker's introduction rather than a review.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why it exists
&lt;/h2&gt;

&lt;p&gt;I read a lot of links I never chose. Research notes, group chats, search results, a citation in&lt;br&gt;
an article — someone else decided the link was worth sharing, and I just want to see what's on&lt;br&gt;
the other end. Making an account to read a single post always felt like a bad trade, and I&lt;br&gt;
didn't want to be logged in while I was just skimming.&lt;/p&gt;

&lt;p&gt;So I made myself a reader. It stayed a personal thing for a while, then it seemed silly not to&lt;br&gt;
share it. It's still a side project: evenings, weekends, one person. That shapes what it does —&lt;br&gt;
everything in it started as something I wanted for myself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it does
&lt;/h2&gt;

&lt;h3&gt;
  
  
  Reading
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Look at an account.&lt;/strong&gt; Someone recommends an account and you want to see what it actually&lt;br&gt;
posts before deciding whether it's worth following anywhere. The&lt;br&gt;
&lt;a href="https://twitterviewer.net/twitter-profile-viewer" rel="noopener noreferrer"&gt;profile viewer&lt;/a&gt; shows the profile, the bio&lt;br&gt;
and recent posts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read a post with its replies.&lt;/strong&gt; Half the time the replies are the point — a claim gets&lt;br&gt;
corrected, or the context arrives three replies down. Paste the post URL and you get the post&lt;br&gt;
together with its reply thread.&lt;/p&gt;

&lt;p&gt;There's a shortcut for both: take any &lt;code&gt;x.com&lt;/code&gt; or &lt;code&gt;twitter.com&lt;/code&gt; URL and swap the host for&lt;br&gt;
&lt;code&gt;twitterviewer.net&lt;/code&gt;. The rest of the path stays the same, so&lt;br&gt;
&lt;code&gt;x.com/someone/status/123&lt;/code&gt; becomes &lt;code&gt;twitterviewer.net/someone/status/123&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Just the images and video.&lt;/strong&gt; If you're looking at an illustrator or a photographer, the text&lt;br&gt;
is noise. The media viewer gives you a photo and video wall for an account and nothing else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Find an account.&lt;/strong&gt; You remember the name but not the handle, or there are four accounts with&lt;br&gt;
almost the same handle and you need the right one. Search by name or handle.&lt;/p&gt;

&lt;h3&gt;
  
  
  Saving media
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Video.&lt;/strong&gt; Paste a post URL, wait for the preview, and&lt;br&gt;
&lt;a href="https://twitterviewer.net/twitter-video-downloader" rel="noopener noreferrer"&gt;download the MP4&lt;/a&gt;. When X serves more than&lt;br&gt;
one resolution, you pick which one you want.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;GIFs.&lt;/strong&gt; This one surprises people. X doesn't store GIFs as GIFs — an uploaded GIF becomes a&lt;br&gt;
looping, silent MP4 — so a plain download hands you an &lt;code&gt;.mp4&lt;/code&gt; no matter what the post looked&lt;br&gt;
like. The &lt;a href="https://twitterviewer.net/twitter-gif-downloader" rel="noopener noreferrer"&gt;GIF downloader&lt;/a&gt; converts it back&lt;br&gt;
to an actual &lt;code&gt;.gif&lt;/code&gt; file, or you can keep the MP4 if that's what you were after.&lt;/p&gt;

&lt;h3&gt;
  
  
  Two odd ones
&lt;/h3&gt;

&lt;p&gt;&lt;strong&gt;Account location.&lt;/strong&gt; Some accounts expose location information in their "about this account"&lt;br&gt;
details. The location checker pulls that up for a public account. It's a small thing, but if&lt;br&gt;
you're trying to work out whether an account is who it says it is, it's one more data point.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Community member lists.&lt;/strong&gt; The&lt;br&gt;
&lt;a href="https://twitterviewer.net/x-communities-exporter" rel="noopener noreferrer"&gt;communities exporter&lt;/a&gt; takes an X Community&lt;br&gt;
URL or ID and gives you the member list as CSV or Excel — handle, display name, verified flag,&lt;br&gt;
role (admin / moderator / member) and profile URL. It caps at 1,000 members and a full export&lt;br&gt;
takes around five minutes.&lt;/p&gt;

&lt;p&gt;There's also an entry point for AI agents, so an agent can read this content without an X&lt;br&gt;
account of its own. I'll write that up separately; it's not something you need to care about&lt;br&gt;
if you're just here to read a post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What it doesn't do
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Public content only.&lt;/strong&gt; Protected accounts aren't visible, and that's deliberate — a locked
account is locked.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read-only.&lt;/strong&gt; You can't post, reply, like or follow. It's a reader, not a client.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;No connection to X.&lt;/strong&gt; It isn't an official product, there's no partnership and no
endorsement. It's a hobby project by someone who is not X.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;It doesn't always work.&lt;/strong&gt; Sometimes a page just won't load its content. When that happens
you get told so rather than a blank page, but "read this later" is the honest answer some of
the time.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Downloaded media isn't yours.&lt;/strong&gt; The copyright stays with whoever made it. Saving something
for reference and reposting it as your own are different things.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Free, and it doesn't want your account
&lt;/h2&gt;

&lt;p&gt;No signup, no login, no payment. There's also no account system on my side, which means there's&lt;br&gt;
nothing for you to hand over — I'm not asking for X credentials and there's nowhere to put them.&lt;/p&gt;

&lt;h2&gt;
  
  
  If you try it
&lt;/h2&gt;

&lt;p&gt;If a page won't load, or there's something you keep wanting it to do and it doesn't, tell me in&lt;br&gt;
the comments. One person maintains this, so the list of what gets fixed next is mostly whatever&lt;br&gt;
people actually complain about.&lt;/p&gt;

</description>
      <category>showdev</category>
      <category>webdev</category>
      <category>productivity</category>
      <category>resources</category>
    </item>
  </channel>
</rss>
