Skip to content

Commit

Permalink
[feat] application versioning and update detection support (#3412)
Browse files Browse the repository at this point in the history
* Version Packages (next)

* Remove optional function and add "reload" option to router's onError configuration

* Add app version and only reload if navigation fails and version changed

* Remove unused code

* Update packages/kit/src/core/build/build_client.js

* Update packages/kit/src/core/build/build_client.js

* Make version and check interval configurable

* Restore original router configuration without error option

* Fix instantiation of "updated" store

* Fix pnpm-lock.yaml

* Update packages/kit/src/core/build/build_client.js

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* Update packages/kit/src/runtime/app/env.js

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* Update packages/kit/src/core/config/options.js

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* Patch tests to work with version timestamp

* Update packages/kit/src/core/adapt/prerender/fixtures/large-page/input.html

* Update packages/kit/src/core/adapt/prerender/fixtures/large-page/output.json

* Update packages/kit/src/core/adapt/prerender/fixtures/large-page/input.html

* Remove version from $app/env

* Apply suggestions from code review

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* Use default version poll interval to 0 (don't poll)

* Only poll if interval is a positive number

* Better documentation of "updated" store

* Apply suggestions from code review

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* Update documentation/docs/14-configuration.md

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* reuse updated store

* return false

* always set poll interval

* lint

* make updated store stop polling once change is detected

* use VITE_SVELTEKIT_ namespace

* Update documentation/docs/14-configuration.md

* Update documentation/docs/14-configuration.md

* oops

* changeset

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Philipp Zerelles <philipp@zerelles.com>
Co-authored-by: Rich Harris <hello@rich-harris.dev>
Co-authored-by: Rich Harris <richard.a.harris@gmail.com>
  • Loading branch information
5 people authored Feb 1, 2022
1 parent 0915768 commit c7fdb89
Show file tree
Hide file tree
Showing 12 changed files with 149 additions and 9 deletions.
5 changes: 5 additions & 0 deletions .changeset/nice-steaks-change.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

Add version config and expose updated store
5 changes: 3 additions & 2 deletions documentation/docs/05-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,20 +56,21 @@ import { base, assets } from '$app/paths';
### $app/stores

```js
import { getStores, navigating, page, session } from '$app/stores';
import { getStores, navigating, page, session, updated } from '$app/stores';
```

Stores are _contextual_ — they are added to the [context](https://svelte.dev/tutorial/context-api) of your root component. This means that `session` and `page` are unique to each request on the server, rather than shared between multiple requests handled by the same server simultaneously, which is what makes it safe to include user-specific data in `session`.

Because of that, the stores are not free-floating objects: they must be accessed during component initialisation, like anything else that would be accessed with `getContext`.

- `getStores` is a convenience function around `getContext` that returns `{ navigating, page, session }`. This needs to be called at the top-level or synchronously during component or page initialisation.
- `getStores` is a convenience function around `getContext` that returns `{ navigating, page, session, updated }`. This needs to be called at the top-level or synchronously during component or page initialisation.

The stores themselves attach to the correct context at the point of subscription, which means you can import and use them directly in components without boilerplate. However, it still needs to be called synchronously on component or page initialisation when the `$`-prefix isn't used. Use `getStores` to safely `.subscribe` asynchronously instead.

- `navigating` is a [readable store](https://svelte.dev/tutorial/readable-stores). When navigating starts, its value is `{ from, to }`, where `from` and `to` are both [`URL`](https://developer.mozilla.org/en-US/docs/Web/API/URL) instances. When navigating finishes, its value reverts to `null`.
- `page` contains an object with the current [`url`](https://developer.mozilla.org/en-US/docs/Web/API/URL), [`params`](#loading-input-params), [`stuff`](#loading-output-stuff), [`status`](#loading-output-status) and [`error`](#loading-output-error).
- `session` is a [writable store](https://svelte.dev/tutorial/writable-stores) whose initial value is whatever was returned from [`getSession`](#hooks-getsession). It can be written to, but this will _not_ cause changes to persist on the server — this is something you must implement yourself.
- `updated` is a [readable store](https://svelte.dev/tutorial/readable-stores) whose initial value is false. If [`version.pollInterval`](#configuration-version) is a non-zero value, SvelteKit will poll for new versions of the app and update the store value to `true` when it detects one. `updated.check()` will force an immediate check, regardless of polling.

### $lib

Expand Down
15 changes: 15 additions & 0 deletions documentation/docs/14-configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,10 @@ const config = {
},
target: null,
trailingSlash: 'never',
version: {
name: Date.now().toString(),
pollInterval: 0,
},
vite: () => ({})
},

Expand Down Expand Up @@ -253,6 +257,17 @@ Whether to remove, append, or ignore trailing slashes when resolving URLs to rou
> Ignoring trailing slashes is not recommended — the semantics of relative paths differ between the two cases (`./y` from `/x` is `/y`, but from `/x/` is `/x/y`), and `/x` and `/x/` are treated as separate URLs which is harmful to SEO. If you use this option, ensure that you implement logic for conditionally adding or removing trailing slashes from `request.path` inside your [`handle`](#hooks-handle) function.
### version
An object containing zero or more of the following values:
- `name` - current app version string
- `pollInterval` - interval in milliseconds to poll for version changes
Client-side navigation can be buggy if you deploy a new version of your app while people are using it. If the code for the new page is already loaded, it may have stale content; if it isn't, the app's route manifest may point to a JavaScript file that no longer exists. SvelteKit solves this problem by falling back to traditional full-page navigation if it detects that a new version has been deployed, using the `name` specified here (which defaults to a timestamp of the build).
If you set `pollInterval` to a non-zero value, SvelteKit will poll for new versions in the background and set the value of the [`updated`](#modules-$app-stores) store to `true` when it detects one.
### vite
A [Vite config object](https://vitejs.dev/config), or a function that returns one. You can pass [Vite and Rollup plugins](https://github.com/vitejs/awesome-vite#plugins) via [the `plugins` option](https://vitejs.dev/config/#plugins) to customize your build in advanced ways such as supporting image optimization, Tauri, WASM, Workbox, and more. SvelteKit will prevent you from setting certain build-related options since it depends on certain configuration values.
9 changes: 9 additions & 0 deletions packages/kit/src/core/build/build_client.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,10 @@ export async function build_client({
output_dir,
client_entry_file
}) {
process.env.VITE_SVELTEKIT_APP_VERSION = config.kit.version.name;
process.env.VITE_SVELTEKIT_APP_VERSION_FILE = `${config.kit.appDir}/version.json`;
process.env.VITE_SVELTEKIT_APP_VERSION_POLL_INTERVAL = `${config.kit.version.pollInterval}`;

create_app({
manifest_data,
output: `${SVELTE_KIT}/generated`,
Expand Down Expand Up @@ -105,6 +109,11 @@ export async function build_client({
const entry_css = new Set();
find_deps(entry, vite_manifest, entry_js, entry_css);

fs.writeFileSync(
`${client_out_dir}/version.json`,
JSON.stringify({ version: process.env.VITE_SVELTEKIT_APP_VERSION })
);

return {
assets,
chunks,
Expand Down
20 changes: 17 additions & 3 deletions packages/kit/src/core/config/index.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,11 @@ const get_defaults = (prefix = '') => ({
router: undefined,
ssr: undefined,
target: null,
trailingSlash: 'never'
trailingSlash: 'never',
version: {
name: Date.now().toString(),
pollInterval: 0
}
}
});

Expand All @@ -110,7 +114,10 @@ test('fills in defaults', () => {

remove_keys(validated, ([, v]) => typeof v === 'function');

assert.equal(validated, get_defaults());
const defaults = get_defaults();
defaults.kit.version.name = validated.kit.version.name;

assert.equal(validated, defaults);
});

test('errors on invalid values', () => {
Expand Down Expand Up @@ -159,6 +166,9 @@ test('fills in partial blanks', () => {
kit: {
files: {
assets: 'public'
},
version: {
name: '0'
}
}
});
Expand All @@ -172,6 +182,7 @@ test('fills in partial blanks', () => {

const config = get_defaults();
config.kit.files.assets = 'public';
config.kit.version.name = '0';

assert.equal(validated, config);
});
Expand Down Expand Up @@ -334,7 +345,10 @@ test('load default config (esm)', async () => {
const config = await load_config({ cwd });
remove_keys(config, ([, v]) => typeof v === 'function');

assert.equal(config, get_defaults(cwd + '/'));
const defaults = get_defaults(cwd + '/');
defaults.kit.version.name = config.kit.version.name;

assert.equal(config, defaults);
});

test('errors on loading config with incorrect default export', async () => {
Expand Down
5 changes: 5 additions & 0 deletions packages/kit/src/core/config/options.js
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,11 @@ const options = object(

trailingSlash: list(['never', 'always', 'ignore']),

version: object({
name: string(Date.now().toString()),
pollInterval: number(0)
}),

vite: validate(
() => ({}),
(input, keypath) => {
Expand Down
2 changes: 2 additions & 0 deletions packages/kit/src/core/dev/plugin.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ export async function create_plugin(config, cwd) {
amp = (await import('./amp_hook.js')).handle;
}

process.env.VITE_SVELTEKIT_APP_VERSION_POLL_INTERVAL = '0';

/** @type {import('types/internal').Respond} */
const respond = (await import(`${runtime}/server/index.js`)).respond;

Expand Down
17 changes: 16 additions & 1 deletion packages/kit/src/runtime/app/stores.js
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ export const getStores = () => {
subscribe: stores.navigating.subscribe
};
},
session: stores.session
session: stores.session,
updated: stores.updated
};
};

Expand Down Expand Up @@ -77,3 +78,17 @@ export const session = {
set: () => throw_error('set'),
update: () => throw_error('update')
};

/** @type {typeof import('$app/stores').updated} */
export const updated = {
subscribe(fn) {
const store = getStores().updated;

if (browser) {
updated.check = store.check;
}

return store.subscribe(fn);
},
check: () => throw_error('check')
};
60 changes: 59 additions & 1 deletion packages/kit/src/runtime/client/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { writable } from 'svelte/store';
import { coalesce_to_error } from '../../utils/error.js';
import { hash } from '../hash.js';
import { normalize } from '../load.js';
import { base } from '../paths.js';

/**
* @typedef {import('types/internal').CSRComponent} CSRComponent
Expand Down Expand Up @@ -39,6 +40,56 @@ function notifiable_store(value) {
return { notify, set, subscribe };
}

function create_updated_store() {
const { set, subscribe } = writable(false);

const interval = +(
/** @type {string} */ (import.meta.env.VITE_SVELTEKIT_APP_VERSION_POLL_INTERVAL)
);
const initial = import.meta.env.VITE_SVELTEKIT_APP_VERSION;

/** @type {NodeJS.Timeout} */
let timeout;

async function check() {
if (import.meta.env.DEV || import.meta.env.SSR) return false;

clearTimeout(timeout);

if (interval) timeout = setTimeout(check, interval);

const file = import.meta.env.VITE_SVELTEKIT_APP_VERSION_FILE;

const res = await fetch(`${base}/${file}`, {
headers: {
pragma: 'no-cache',
'cache-control': 'no-cache'
}
});

if (res.ok) {
const { version } = await res.json();
const updated = version !== initial;

if (updated) {
set(true);
clearTimeout(timeout);
}

return updated;
} else {
throw new Error(`Version check failed: ${res.status}`);
}
}

if (interval) timeout = setTimeout(check, interval);

return {
subscribe,
check
};
}

/**
* @param {RequestInfo} resource
* @param {RequestInit} [opts]
Expand Down Expand Up @@ -108,7 +159,8 @@ export class Renderer {
url: notifiable_store({}),
page: notifiable_store({}),
navigating: writable(/** @type {Navigating | null} */ (null)),
session: writable(session)
session: writable(session),
updated: create_updated_store()
};

this.$session = null;
Expand Down Expand Up @@ -274,6 +326,12 @@ export class Renderer {
location.href = new URL(navigation_result.redirect, location.href).href;
}

return;
}
} else if (navigation_result.props?.page?.status >= 400) {
const updated = await this.stores.updated.check();
if (updated) {
location.href = info.url.href;
return;
}
}
Expand Down
10 changes: 8 additions & 2 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import devalue from 'devalue';
import { writable } from 'svelte/store';
import { readable, writable } from 'svelte/store';
import { coalesce_to_error } from '../../../utils/error.js';
import { hash } from '../../hash.js';
import { escape_html_attr } from '../../../utils/escape.js';
Expand All @@ -9,6 +9,11 @@ import { Csp, csp_ready } from './csp.js';

// TODO rename this function/module

const updated = {
...readable(false),
check: () => false
};

/**
* @param {{
* branch: Array<import('./types').Loaded>;
Expand Down Expand Up @@ -85,7 +90,8 @@ export async function render_response({
stores: {
page: writable(null),
navigating: writable(null),
session
session,
updated
},
page: {
url: state.prerender ? create_prerendering_url_proxy(url) : url,
Expand Down
6 changes: 6 additions & 0 deletions packages/kit/types/ambient-modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ declare module '$app/stores' {
navigating: typeof navigating;
page: typeof page;
session: Writable<Session>;
updated: typeof updated;
};
/**
* A readable store whose value contains page data.
Expand All @@ -132,6 +133,11 @@ declare module '$app/stores' {
* It can be written to, but this will not cause changes to persist on the server — this is something you must implement yourself.
*/
export const session: Writable<any>;
/**
* A writable store indicating if the site was updated since the store was created.
* It can be written to when custom logic is required to detect updates.
*/
export const updated: Readable<boolean> & { check: () => boolean };
}

declare module '$service-worker' {
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/types/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,10 @@ export interface Config {
};
target?: string;
trailingSlash?: TrailingSlash;
version?: {
name?: string;
pollInterval?: number;
};
vite?: ViteConfig | (() => MaybePromise<ViteConfig>);
};
preprocess?: any;
Expand Down

0 comments on commit c7fdb89

Please sign in to comment.