A hack to fetch a URL synchronously in Svelte (Astro)

Svelte doesn't currently have top level await, meaning that in a server-side rendering context, it is impossible to, say, wait a fetch to finish before sending it to the client. There is the #await block, but I want the wait to happen on the server. (I don't care about the full page load taking a little longer as my use case is easily cacheable and already cached on Cloudflare.)

<script>
  export let tl = "Guá ū tsi̍t-ē mī-kiānn bē hōo lí khuànn";
  async function toPOJ(text) {
    const response = await fetch("https://pojtl.kemdict.com/toPOJ", {
      method: "POST",
      body: text,
    });
    return response.text();
  }

  // const result = await toPOJ(tl);
  //   -> error as Svelte doesn't have top level await
</script>

<div>
  {#await toPOJ(tl)}
    <span>...</span>
  {:then value}
    <!-- Waited on the client side -->
    <span>{value}</span>
  {/await}
</div>

There are a few options for me here:

  • Since I'm using Astro, it is relatively easy to just switch to, say, React or .astro for this specific component. But in my use case this would require porting a large number of components.
  • I could also just bite the bullet and allow the value to be waited on the client side.

Or, well… there's a hack that doesn't require porting and can still wait for the fetch on the server side. The downside is just that it's admittedly stupid.

One other point I haven't mentioned is that I'm deploying the application in SSR mode to Node, so I have access to Node builtins. Including node:child_process, which has the execSync family, which will run a process, wait for it to finish, and return its output.

So the hack is to run a child process (shelling out, though I'm not going through a shell), and have that child process do the actual network request. The child process can be a call to curl or literally any other program, but I'm going to use Node because it's more familiar.

This is the script I ended up with:

// hack/postSync.mjs
/**
 ,* @file POST some stuff to a URL.
 ,* Usage: one of
 ,*   echo input | node postSync.mjs <url>
 ,*   node postSync.mjs <url> <input>
 ,*/

// The argument count would break if called as a standalone script.
const url = process.argv[2] || process.exit(1);

async function do_it(text) {
  const response = await fetch(url, {
    method: "POST",
    body: text,
  });
  console.log(await response.text());
}

// Support both reading from stdin and reading from the second argument
// Because I'm not sure about the length limit of an argument
let text = "";
if (process.argv.length > 3) {
  text = process.argv[3];
  do_it(text);
} else {
  process.stdin.on("readable", () => {
    let chunk;
    while (null !== (chunk = process.stdin.read())) {
      text += chunk;
    }
  });
  process.stdin.on("end", () => {
    do_it(text);
  });
}

And I'm not even bothering with a top level await in this script, because Node will only exit when the async function finishes.

It's then called like this:

import { spawnSync } from "node:child_process";

export function toPOJ(text) {
  return spawnSync(
    "node",
    ["hack/postSync.mjs", "https://pojtl.kemdict.com/toPOJ"],
    { input: text }
  ).stdout;
}

This function, from the point of view of the main application, is just a normal non-async function that happens to block a little bit. If there's a need to, say, fetch multiple URLs, I can also modify the postSync script to accept multiple URLs and await them all at once there.

This hopefully isn't too much debt to take on, and is probably a reasonable stopgap solution until Svelte maybe eventually gets support for top level await.