Skip to content

Commit

Permalink
[feat] Allow server-only load functions to return more than JSON (#…
Browse files Browse the repository at this point in the history
…6318)

closes #6008
fixes #6357

This allows load functions to return things like Date objects, regexes, Map and Set, BigInt and so on. It also allows repeated and cyclical references, for the times when they're useful.
  • Loading branch information
Rich-Harris committed Aug 29, 2022
1 parent c228724 commit c530d33
Show file tree
Hide file tree
Showing 31 changed files with 423 additions and 314 deletions.
7 changes: 7 additions & 0 deletions .changeset/tender-spiders-fail.md
@@ -0,0 +1,7 @@
---
'@sveltejs/adapter-netlify': patch
'@sveltejs/adapter-vercel': patch
'@sveltejs/kit': patch
---

Use devalue to serialize server-only `load` return values
2 changes: 1 addition & 1 deletion documentation/docs/03-routing.md
Expand Up @@ -106,7 +106,7 @@ export async function load({ params }) {
}
```

During client-side navigation, SvelteKit will load this data using `fetch`, which means that the returned value must be serializable as JSON.
During client-side navigation, SvelteKit will load this data from the server, which means that the returned value must be serializable using [devalue](https://github.com/rich-harris/devalue).

#### Actions

Expand Down
4 changes: 2 additions & 2 deletions documentation/docs/05-load.md
Expand Up @@ -4,7 +4,7 @@ title: Loading data

A [`+page.svelte`](/docs/routing#page-page-svelte) or [`+layout.svelte`](/docs/routing#layout-layout-svelte) gets its `data` from a `load` function.

If the `load` function is defined in `+page.js` or `+layout.js` it will run both on the server and in the browser. If it's instead defined in `+page.server.js` or `+layout.server.js` it will only run on the server, in which case it can (for example) make database calls and access private [environment variables](/docs/modules#$env-static-private), but can only return data that can be serialized as JSON. In both cases, the return value (if there is one) must be an object.
If the `load` function is defined in `+page.js` or `+layout.js` it will run both on the server and in the browser. If it's instead defined in `+page.server.js` or `+layout.server.js` it will only run on the server, in which case it can (for example) make database calls and access private [environment variables](/docs/modules#$env-static-private), but can only return data that can be serialized with [devalue](https://github.com/rich-harris/devalue). In both cases, the return value (if there is one) must be an object.

```js
/// file: src/routes/+page.js
Expand Down Expand Up @@ -256,7 +256,7 @@ export async function load({ setHeaders }) {

### Output

The returned `data`, if any, must be an object of values. For a server-only `load` function, these values must be JSON-serializable. Top-level promises will be awaited, which makes it easy to return multiple promises without creating a waterfall:
The returned `data`, if any, must be an object of values. For a server-only `load` function, these values must be serializable with [devalue](https://github.com/rich-harris/devalue). Top-level promises will be awaited, which makes it easy to return multiple promises without creating a waterfall:

```js
// @filename: $types.d.ts
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-netlify/index.js
Expand Up @@ -211,7 +211,7 @@ async function generate_lambda_functions({ builder, publish, split, esm }) {
writeFileSync(`.netlify/functions-internal/${name}.js`, fn);

redirects.push(`${pattern} /.netlify/functions/${name} 200`);
redirects.push(`${pattern}/__data.json /.netlify/functions/${name} 200`);
redirects.push(`${pattern}/__data.js /.netlify/functions/${name} 200`);
}
};
});
Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-vercel/index.js
Expand Up @@ -224,7 +224,7 @@ export default function ({ external = [], edge, split } = {}) {
sliced_pattern = '^/?';
}

const src = `${sliced_pattern}(?:/__data.json)?$`; // TODO adding /__data.json is a temporary workaround — those endpoints should be treated as distinct routes
const src = `${sliced_pattern}(?:/__data.js)?$`; // TODO adding /__data.js is a temporary workaround — those endpoints should be treated as distinct routes

await generate_function(route.id || 'index', src, entry.generateManifest);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/package.json
Expand Up @@ -12,7 +12,7 @@
"dependencies": {
"@sveltejs/vite-plugin-svelte": "^1.0.1",
"cookie": "^0.5.0",
"devalue": "^2.0.1",
"devalue": "^3.1.2",
"kleur": "^4.1.4",
"magic-string": "^0.26.2",
"mime": "^3.0.0",
Expand Down
Expand Up @@ -3,3 +3,5 @@
export const SVELTE_KIT_ASSETS = '/_svelte_kit_assets';

export const GENERATED_COMMENT = '// this file is generated — do not edit it\n';

export const DATA_SUFFIX = '/__data.js';
2 changes: 1 addition & 1 deletion packages/kit/src/core/env.js
@@ -1,4 +1,4 @@
import { GENERATED_COMMENT } from './constants.js';
import { GENERATED_COMMENT } from '../constants.js';
import { runtime_base } from './utils.js';

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/core/sync/write_ambient.js
@@ -1,6 +1,6 @@
import path from 'path';
import { get_env } from '../../exports/vite/utils.js';
import { GENERATED_COMMENT } from '../constants.js';
import { GENERATED_COMMENT } from '../../constants.js';
import { create_types } from '../env.js';
import { write_if_changed } from './utils.js';

Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/exports/vite/dev/index.js
Expand Up @@ -8,7 +8,7 @@ import { installPolyfills } from '../../../exports/node/polyfills.js';
import { coalesce_to_error } from '../../../utils/error.js';
import { posixify } from '../../../utils/filesystem.js';
import { load_template } from '../../../core/config/index.js';
import { SVELTE_KIT_ASSETS } from '../../../core/constants.js';
import { SVELTE_KIT_ASSETS } from '../../../constants.js';
import * as sync from '../../../core/sync/sync.js';
import { get_mime_lookup, runtime_base, runtime_prefix } from '../../../core/utils.js';
import { get_env, prevent_illegal_vite_imports, resolve_entry } from '../utils.js';
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/exports/vite/preview/index.js
Expand Up @@ -4,7 +4,7 @@ import sirv from 'sirv';
import { pathToFileURL } from 'url';
import { getRequest, setResponse } from '../../../exports/node/index.js';
import { installPolyfills } from '../../../exports/node/polyfills.js';
import { SVELTE_KIT_ASSETS } from '../../../core/constants.js';
import { SVELTE_KIT_ASSETS } from '../../../constants.js';
import { loadEnv } from 'vite';

/** @typedef {import('http').IncomingMessage} Req */
Expand Down
123 changes: 69 additions & 54 deletions packages/kit/src/runtime/client/client.js
Expand Up @@ -10,6 +10,7 @@ import Root from '__GENERATED__/root.svelte';
import { nodes, server_loads, dictionary, matchers } from '__GENERATED__/client-manifest.js';
import { HttpError, Redirect } from '../control.js';
import { stores } from './singletons.js';
import { DATA_SUFFIX } from '../../constants.js';

const SCROLL_KEY = 'sveltekit:scroll';
const INDEX_KEY = 'sveltekit:index';
Expand Down Expand Up @@ -393,7 +394,7 @@ export function create_client({ target, base, trailing_slash }) {
* status: number;
* error: HttpError | Error | null;
* routeId: string | null;
* validation_errors?: string | undefined;
* validation_errors?: Record<string, any> | null;
* }} opts
*/
async function get_navigation_result_from_branch({
Expand Down Expand Up @@ -715,24 +716,14 @@ export function create_client({ target, base, trailing_slash }) {

if (invalid_server_nodes.some(Boolean)) {
try {
const res = await native_fetch(
`${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`,
{
headers: {
'x-sveltekit-invalidated': invalid_server_nodes.map((x) => (x ? '1' : '')).join(',')
}
}
);

server_data = /** @type {import('types').ServerData} */ (await res.json());

if (!res.ok) {
throw server_data;
}
} catch (e) {
// something went catastrophically wrong — bail and defer to the server
native_navigation(url);
return;
server_data = await load_data(url, invalid_server_nodes);
} catch (error) {
return load_root_error_page({
status: 500,
error: /** @type {Error} */ (error),
url,
routeId: route.id
});
}

if (server_data.type === 'redirect') {
Expand Down Expand Up @@ -882,19 +873,18 @@ export function create_client({ target, base, trailing_slash }) {
if (node.server) {
// TODO post-https://github.com/sveltejs/kit/discussions/6124 we can use
// existing root layout data
const res = await native_fetch(
`${url.pathname}${url.pathname.endsWith('/') ? '' : '/'}__data.json${url.search}`,
{
headers: {
'x-sveltekit-invalidated': '1'
}
}
);
try {
const server_data = await load_data(url, [true]);

const server_data_nodes = await res.json();
server_data_node = server_data_nodes?.[0] ?? null;
if (
server_data.type !== 'data' ||
(server_data.nodes[0] && server_data.nodes[0].type !== 'data')
) {
throw 0;
}

if (!res.ok || server_data_nodes?.type !== 'data') {
server_data_node = server_data.nodes[0] ?? null;
} catch {
// at this point we have no choice but to fall back to the server
native_navigation(url);

Expand Down Expand Up @@ -1298,32 +1288,24 @@ export function create_client({ target, base, trailing_slash }) {
});
},

_hydrate: async ({ status, error, node_ids, params, routeId }) => {
_hydrate: async ({
status,
error: original_error, // TODO get rid of this
node_ids,
params,
routeId,
data: server_data_nodes,
errors: validation_errors
}) => {
const url = new URL(location.href);

/** @type {import('./types').NavigationFinished | undefined} */
let result;

try {
/**
* @param {string} type
* @param {any} fallback
*/
const parse = (type, fallback) => {
const script = document.querySelector(`script[sveltekit\\:data-type="${type}"]`);
return script?.textContent ? JSON.parse(script.textContent) : fallback;
};
/**
* @type {Array<import('types').ServerDataNode | null>}
* On initial navigation, this will only consist of data nodes or `null`.
* A possible error is passed through the `error` property, in which case
* the last entry of `node_ids` is an error page and the last entry of
* `server_data_nodes` is `null`.
*/
const server_data_nodes = parse('server_data', []);
const validation_errors = parse('validation_errors', undefined);

const branch_promises = node_ids.map(async (n, i) => {
const server_data_node = server_data_nodes[i];

return load_node({
loader: nodes[n],
url,
Expand All @@ -1336,7 +1318,7 @@ export function create_client({ target, base, trailing_slash }) {
}
return data;
},
server_data_node: create_data_node(server_data_nodes[i])
server_data_node: create_data_node(server_data_node)
});
});

Expand All @@ -1345,13 +1327,15 @@ export function create_client({ target, base, trailing_slash }) {
params,
branch: await Promise.all(branch_promises),
status,
error: /** @type {import('../server/page/types').SerializedHttpError} */ (error)
error: /** @type {import('../server/page/types').SerializedHttpError} */ (original_error)
?.__is_http_error
? new HttpError(
/** @type {import('../server/page/types').SerializedHttpError} */ (error).status,
error.message
/** @type {import('../server/page/types').SerializedHttpError} */ (
original_error
).status,
original_error.message
)
: error,
: original_error,
validation_errors,
routeId
});
Expand All @@ -1377,3 +1361,34 @@ export function create_client({ target, base, trailing_slash }) {
}
};
}

let data_id = 1;

/**
* @param {URL} url
* @param {boolean[]} invalid
* @returns {Promise<import('types').ServerData>}
*/
async function load_data(url, invalid) {
const data_url = new URL(url);
data_url.pathname = url.pathname.replace(/\/$/, '') + DATA_SUFFIX;
data_url.searchParams.set('__invalid', invalid.map((x) => (x ? 'y' : 'n')).join(''));
data_url.searchParams.set('__id', String(data_id++));

// The __data.js file is generated by the server and looks like
// `window.__sveltekit_data = ${devalue(data)}`. We do this instead
// of `export const data` because modules are cached indefinitely,
// and that would cause memory leaks.
//
// The data is read and deleted in the same tick as the promise
// resolves, so it's not vulnerable to race conditions
await import(/* @vite-ignore */ data_url.href);

// @ts-expect-error
const server_data = window.__sveltekit_data;

// @ts-expect-error
delete window.__sveltekit_data;

return server_data;
}
2 changes: 2 additions & 0 deletions packages/kit/src/runtime/client/start.js
Expand Up @@ -20,6 +20,8 @@ export { set_public_env } from '../env-public.js';
* node_ids: number[];
* params: Record<string, string>;
* routeId: string | null;
* data: Array<import('types').ServerDataNode | null>;
* errors: Record<string, any> | null;
* };
* }} opts
*/
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/runtime/client/types.d.ts
Expand Up @@ -27,6 +27,8 @@ export interface Client {
node_ids: number[];
params: Record<string, string>;
routeId: string | null;
data: Array<import('types').ServerDataNode | null>;
errors: Record<string, any> | null;
}) => Promise<void>;
_start_router: () => void;
}
Expand Down

0 comments on commit c530d33

Please sign in to comment.