sveltejs / kit

web development, streamlined
https://svelte.dev/docs/kit
MIT License
18.73k stars 1.94k forks source link

Shadow endpoints (makes `load` boilerplate unnecessary in many cases) #3532

Closed Rich-Harris closed 2 years ago

Rich-Harris commented 2 years ago

Describe the problem

A significant number of pages have load functions that are essentially just boilerplate:

<!-- src/routes/blog/[slug].svelte -->
<script context="module">
  /** @type {import('@sveltejs/kit').Load */
  export async function load({ params }) {
    const res = await fetch(`/blog/${params.slug}.json`);
    const { post } = await res.json();

    return {
      props: { post }
    };
  }
</script>

<script>
  export let post;
</script>
// src/routes/blog/[slug].json.js
import * as db from '$lib/db';

export function get({ params }) {
  const post = await db.get('post', params.slug);

  if (!post) {
    return { status: 404 };
  }

  return {
    body: { post }
  }  
}

The load function even contains a bug — it doesn't handle the 404 case.

While we definitely do need load for more complex use cases, it feels like we could drastically simplify this. This topic has come up before (e.g. #758), but we got hung up on the problems created by putting endpoint code inside a Svelte file (you need magic opinionated treeshaking, and scoping becomes nonsensical).

Describe the proposed solution

I think we can solve the problem very straightforwardly: if a route maps to both a page and an endpoint, we regard the endpoint (hereafter 'shadow endpoint') as providing the data to the page. In other words, /blog/[slug] contains both the data (JSON) and the view of the data (HTML), depending on the request's Accept header. (This isn't a novel idea; this is how HTTP has always worked.)

In the example above, no changes to the endpoint would be necessary other than renaming [slug].json.js to [slug].js. The page, meanwhile, could get rid of the entire context="module" script.

One obvious constraint: handlers in shadow endpoints need to return an object of props. Since we already handle serialization to JSON, this is already idiomatic usage, and can easily be enforced.

POST requests

This becomes even more useful with POST. Currently, SvelteKit can't render a page as a result of a POST request, unless the handler redirects to a page. At that point, you've lost the ability to return data (for example, form validation errors) from the handler. With this change, validation errors could be included in the page props.

We do, however, run into an interesting problem here. Suppose you have a /todos page like the one from the demo app...

import * as db from 'db';

export async function get({ locals }) {
  const todos = (await db.get('todo', locals.user)) || [];
  return {
    body: { todos }
  };
}

export async function post({ locals, request }) {
  const data = await request.formData();
  const description = data.get('description');

  // validate input
  if (description === 'eat hot chip and lie') {
    return {
      status: 400,
      body: {
        values: { description },
        errors: { description: 'invalid description' }
      }
    }
  }

  await db.post('todo', locals.user, { done: false, description });

  return {
    status: 201
  };
}

...and a page that receives the todos, values and errors props...

<script>
  export let todos;
  export let values; // used for prepopulating form fields if recovering from an error
  export let errors; // used for indicating which fields were bad
</script>

...then the initial GET would be populated with todos, but when rendering the page following a POST to /todos, the todos prop would be undefined.

I think the way to solve this would be to run the get handler after the post handler has run, and combine the props:

// pseudo-code
const post_result = await post.call(null, event);
const get_result = await get.call(null, event);

html = render_page({
  ...get_result.body,
  ...post_result.body
});

It might look odd, but there is some precedent for this — Remix's useLoaderData and useActionData, the latter of which is only populated after a mutative request.

This feels to me like the best solution to #1711.

Combining with load

In some situations you might still need load — for example, in a photo app you might want to delay navigation until you've preloaded the image to avoid flickering. It would be a shame to have to reintroduce all the load boilerplate at that point.

We could get the best of both worlds by feeding the props from the shadow endpoint into load:

<script context="module">
  import { browser } from '$app/env';

  /** @type {import('@sveltejs/kit').Load */
  export async function load({ props }) {
    if (browser) {
      await new Promise((fulfil, reject) => {
        const img = new Image();
        img.onload = () => fulfil();
        img.onerror = e => reject(new Error('Failed to load image', { cause: e }));
        img.src = props.photo.src;
      });
    }

    return { props };
  }
</script>

Prerendering

One wrinkle in all this is prerendering. If you've deployed your app as static files, you have most likely lost the ability to do content negotiation, meaning that even though we work around filename conflicts by appending /index.html to pages, there's no way to specify you want the JSON version of /blog/my-article.

Also, the MIME type of prerendered-pages/blog/my-article would be wrong, since static fileservers typically derive the MIME type from the file extension.

One solution: shadow endpoints are accessed via a different URL, like /_data/blog/my-article.json (hitting the endpoint directly with an Accept header would essentially just proxy to the shadow endpoint URL). App authors would need to take care to ensure that they weren't manually fetching data from /blog/my-article in a prerendered app. In the majority of cases, shadow endpoints would eliminate the need for fetch, so this seems feasible.

Alternatives considered

The main alternative idea is #758. A similar version of this has been explored by https://svemix.com. I'm personally more inclined towards shadow endpoints, for several reasons:

The other alternative is to do none of this. I think that would be a mistake — the load boilerplate really is unfortunate, but moreover I don't see any other good way to solve #1711.

Importance

would make my life easier

Additional Information

No response

dominikg commented 2 years ago

I really like that context="module" isn't needed in this. :+1:

Some things to discuss for "get-after-post"

error handling

What happens if the get or post fail. The more interesting case is post succeeds and get fails (e.g. shaky network)

added latency

get only runs after post has returned, adding another roundtrip resulting in a delayed response. Is this still a net-win because the get would have happened next either way and we saved the client->kit-server part for that?

customization

how would developers be able to opt out of this if the additional get request would cause issues for the underlying api services.

Rich-Harris commented 2 years ago

Good questions!

The more interesting case is post succeeds and get fails (e.g. shaky network)

To clarify, get-after-post doesn't mean the client would be making any additional requests — everything would be happening on the server, which would coalesce the post and get return values and use that to render the page. It's certainly possible that the get handler could fail (an external API goes down, or the database fails) but we don't have to deal with the scenario where the user goes into the tunnel between post being called and get being called — as far as the client <-> SvelteKit app relationship is concerned, everything is happening in a single round trip.

We can't compare the cost of calling post+get with the cost of just calling post because it simply wouldn't be possible to render the shadowed page without the get — there'd be no data. But we can compare it to the nearest equivalent available today, which is posting to a separate endpoint that redirects back to the current page — e.g. we post from /todos to /todos.json, which updates the database then redirects back to /todos.

In that scenario, we call the /todos.json post handler, the browser receives the 303 response, then the browser makes a request for /todos, which results in Kit calling the /todos.json get handler. We've called the same handlers the same number of times, but we've rendered the updated /todos page in two roundtrips instead of one. So it's absolutely a net win.

(That's for the case where we're relying on the browser's default behaviour. If we're progressively enhancing a la #3533, then the POST request will only invoke the post handler, not post + get, because we're not rendering a shadowed page.)

how would developers be able to opt out of this if the additional get request would cause issues for the underlying api services

In the rare case where it really is appropriate for get to skip doing any work, you could perhaps do it like this:

export async function get({ request }) {
  if (request.method !== 'GET') return {};
  // ...
}

The post handler could pass data to the get handler via event.locals, if necessary. Would be unusual though I think.

arxpoetica commented 2 years ago

Huge fan of this proposal. I've often thought the load boilerplate felt a bit hefty..."oh, I have to go do this again?" In fact, I've set up my own load wrappers trying to simplify, but it still requires boilerplate.

Love this.

Theo-Steiner commented 2 years ago

Wow, this does look super clean. However, I am a bit worried if this behavior might not actually be too clean. Sure, there is quite a lot of load boilerplate even for the simplest use cases, but this comes with the upside of being very obvious to parse what's going on. With one look you know that the props for this route come from the endpoint that is fetched inside the load function. A shadow endpoint makes this more invisible, and I think especially for beginners this could be quite the head scratcher. The svemix approach of mixing front and backend code for small endpoints comes with its own set of downsides, but to me it is more "obvious" in terms of data flow.

Karlinator commented 2 years ago

The extra learning gap is unfortunate, but I think it shouldn't be too hard to pick up particularly if you see it in a project (or in the demo project). The fact that there are two files with the same name should tip you off that something is going on. A quick trip to the docs should then set you straight.

While there are some hairy details, I think the conceptual "the endpoint gives props to the page" is simple enough, and the details can then be filled in.

If you start with an empty Kit project and somehow don't come over this, the worst case scenario is basically that you revert to the current situation of explicitly loading props.

And besides the learning gap I think this is great. I'd love to cut that boilerplate in all my pages.

Acmion commented 2 years ago

I like this a lot and it addresses most of the key concerns of #3483.

I especially like the fact that one can access serverside resources (e.g. a database) almost directly.

The problem with load is that it is executed on both the server and the client, which means that one has to deal with this limitation. Often the solution is to create actual endpoints, which can be called from both the server and the client. However, this means that one has to explicitly maintain an "untyped" URL as well as the "untyped" response.

Thus, this solution is a significant improvement on load.

f-elix commented 2 years ago

Would there be an equivalent for layouts load functions? Something like a __layout.js endpoint that populates the layout component with the returned props?

Glench commented 2 years ago

First of all, love that you're thinking about this. This has been one of my key frustrations with SvelteKit. The solution you've come to is basically what I ended up with, too — routes can either return JSON or HTML depending on the accept header.

Second, regarding this comment:

A shadow endpoint makes this more invisible, and I think especially for beginners this could be quite the head scratcher.

I think that might be true. There would definitely be a bit more magic to learn, but there's already magic in knowing about [slug] routes for example, or __layout. The tradeoff is the confusion about where data is coming from (mostly for beginners) vs. removing 100% of boilerplate for the most common case. I think the latter is a good way to make more developers love SvelteKit — common things are easy, complicated things are possible.

But maybe there's a one-liner that could be added to make it explicit? Not sure what that would look like.

lukaszpolowczyk commented 2 years ago

@Glench In the documentation, write in one sentence that shadow endpoint is similar to __layout and by analogy even a novice will know what is going on.

Rich-Harris commented 2 years ago

3679 was merged, so I'll close this. You can see how we've tackled the documentation — essentially, we're teaching shadow endpoints as the default (no mention of the word 'shadow'), and standalone endpoints as the exception.

To @f-elix's point:

Would there be an equivalent for layouts load functions? Something like a __layout.js endpoint that populates the layout component with the returned props?

That probably makes sense, yeah. But I think it might turn out to be a bit of a design rabbit hole because the word __layout makes very little sense in that context, since it's really a UI word (it's already a bit weird, since layouts can do non-UI things like redirect and populate stuff), and we probably need to come up with some better nomenclature and/or generally tidy up the conceptual landscape. Rather than try to solve all that immediately, it's probably better to let stuff percolate for a minute and come up with proposals in a new issue.

f-elix commented 2 years ago

@Rich-Harris Thanks so much for implementing this so fast, this is a game changer!

robots4life commented 2 years ago

if a route maps to both a page and an endpoint - this encourages having both files in the same place as opposed to separating into an api folder. Will this be the way forward ?

pzuraq commented 2 years ago

This change broke an important use case for us. For certain pages, we use endpoints to proxy HTML content. This allows us to display custom HTML pages for certain users, while having a normal SvelteKit based page for the rest.

If the shadow endpoint's response has a header of Content-Type: text/html could it bypass this system? Currently it's just throwing a 500 without any logs so I'm unsure what the issue is exactly, but I'm assuming it's trying and failing to parse the body as JSON.

pzuraq commented 2 years ago

I made a quick PR to show how this could potentially be done, if there's interest in the feature I can finish it up and add tests/fix types/etc.

madeleineostoja commented 2 years ago

Amazed this was shipped so fast, I came across the issue a week ago then checked the docs today for an unrelated thing and saw it documented as the default!

I ran into a few rough edges:

bummzack commented 2 years ago

I also really like this new feature. What we also do often is to load some data in __layout and pass it to all other routes via stuff. Sadly stuff isn't available in the shadow endpoints, so we still have to resort to using the load approach… I didn't check the implementation details to see if that's just an oversight, or impossible due to the architecture of things?

deshartman commented 2 years ago

Unfortunately, this has broken the static adapter. Please see #3833

gkatsanos commented 2 years ago

Sorry for posting in a closed discussion, arrived here after getting the link from the Discord server while asking for some hints on when to use load() and when to use get() (in a page endpoint). After reading the initial description, it seems to me page endpoints are a simplified version of load() - not syntactic sugar per-se, as they compile to something different, but a way to do the same thing with less code. I think this is worth clarifying in the documentation: Some clear-cut pointers on what to use and when. The question I'm trying to understand specifically is, What doesload()give you access to that page endpoints don't in terms of context? (e.g. one is Server-side call, the other can be both server and client side, or access to the url params maybe or other stuff) I'd even go as far as having a table with use-cases as I predict some folks will have similar questions..

thanks!

jaclas commented 2 years ago

Yes, the documentation should be more precise and detailed. I'm confused, I currently don't know where to locate the data retrieval from the external server. I'd like to have a centralized place where I handle external APIs and potential errors, but I guess I have to spread everything across multiple endpoints? I don't know. I feel chaos.

bummzack commented 2 years ago

@gkatsanos the page endpoint code always runs on the server. So if you access a protected API (eg. a database or similar which involves credentials that should not leak to the frontend), you should always use page-endpoints.

In the page-endpoints, you do not have access to the stuff data from your __layout.svelte routes #3860 and you can only send serializable data. On the other hand it's basically impossible to build proper form-handling without using page-endpoints.

Personally, I think page-endpoints should be preferred and only use load when you need to pass data from __layout.svelte to your routes (using stuff) or when you need to load data that is not serializable in your load function (eg. dynamically importing modules).

Of course you can also combine the page-endpoint and load, as demonstrated here: https://github.com/sveltejs/kit/issues/3860#issuecomment-1057543382