svelte-pilot
A svelte router with SSR (Server-Side Rendering) support.
- svelte-pilot
- Install
- Usage
- Constructor
- routes
- base
- pathQuery
- mode
- Route object
- Location object
- <RouterLink>
- Properties
- Methods
- Get current route and router instance in components
- License
Install
npm install svelte-pilot
Usage
Checkout svelte-vite-ssr template.
Client-Side Rendering
import { Router, ClientApp } from 'svelte-pilot';
const router = new Router({
// options
});
new ClientApp({
target: document.body,
props: {
router
}
});
Server-Side Rendering
Client entry
client.ts
:
import { ClientApp, SSRState } from 'svelte-pilot';
import router from './router';
declare global {
interface Window {
__SSR_STATE__: SSRState
}
}
// wait unitl async components loaded
// prevent screen flash
router.once('update', () =>
new ClientApp({
target: document.body,
hydrate: true,
props: {
router,
ssrState: window.__SSR_STATE__
}
})
);
Server entry
server-render.ts
:
import { ServerApp } from 'svelte-pilot';
import router from './router';
type RenderParams = {
url: string,
ctx?: unknown,
template: string,
};
type RenderResult = {
error?: Error,
status: number,
headers?: Record<string, string>,
body?: string
};
export default async function(args: RenderParams): Promise<RenderResult> {
try {
return await render(args);
} catch (e) {
return {
error: e,
status: 500,
headers: {
'Content-Type': 'text/html',
'Cache-Control': 'no-store'
},
body: args.template // Fallback to CSR
};
}
}
async function render({ url, ctx, template }: RenderParams): Promise<RenderResult> {
const matchedRoute = await router.handle('http://127.0.0.1' + url, ctx);
if (!matchedRoute) {
console.error('No route found for url:', url);
if (new URL(url, 'http://127.0.0.1').pathname === '/') {
return {
status: 404,
body: 'Page Not Found',
headers: { 'content-type': 'text/plain' }
};
} else {
return {
status: 301,
headers: {
location: '/',
'Cache-Control': 'no-store'
}
};
}
}
const { route, ssrState } = matchedRoute;
const res = route.meta.response as { status?: number, headers?: Record<string, string> } | null;
if (res?.headers?.location) {
return {
status: res.status || 301,
headers: res.headers
};
} else {
const body = ServerApp.render({ router, route, ssrState });
body.html += `<script>__SSR_STATE__ = ${serialize(ssrState)}</script>`;
return {
status: res?.status || 200,
headers: {
'Content-Type': 'text/html',
...res?.headers
},
body: template
.replace('</head>', body.head + '<style>' + body.css.code + '</style></head>')
.replace('<body>', '<body>' + body.html)
};
}
}
function serialize(data: unknown) {
return JSON.stringify(data).replace(/</g, '\\u003C').replace(/>/g, '\\u003E');
}
server.ts
import http from 'http';
import path from 'path';
import serveStatic from 'serve-static';
import render from './server-render';
// @ts-expect-error handle by rollup-plugin-string
import template from './index.html';
const PORT = Number(process.env.PORT) || 3000;
const serve = serveStatic(path.resolve(__dirname, '../client'));
http.createServer(async(req, res) => {
const url = req.url as string;
console.log(url);
if (url.startsWith('/_assets/')) {
serve(req, res, () => {
res.statusCode = 404;
res.end('Not Found');
});
} else {
const { error, status, headers, body } = await render(
{
url,
template,
ctx: {
cookies: req.headers.cookie
? Object.fromEntries(
new URLSearchParams(req.headers.cookie.replace(/;\s*/g, '&'))
// @ts-expect-error Property 'entries' does not exist on type 'URLSearchParams'.ts(2339)
.entries()
)
: {},
headers: req.headers
}
}
);
if (error) {
console.error(error);
}
res.writeHead(status, headers);
res.end(body);
}
}).listen(PORT);
Fetch data on server side
Svelte component can export a load
function to fetch the data on server side.
load
function arguments:
props
:props
defined in the RouterView config.route
: The current Route object.ssrContext
: anything you passed torouter.handle()
.
The returned state object will be passed to component props.
On the client side, when a navigation is triggered through history.pushState
/ history.replaceState
/ popstate
event,
the state object will be purged.
<script context="module">
import Child, { load as loadChild } from './Child.svelte';
export async function load(props, route, ssrCtx) {
// Mock http request
const ssrState = await fetchData(props.page, token: ssrCtx.cookies.token);
// load child component
const childState = await loadChild({ foo: ssrState.foo }, route, ssrCtx);
// Set response headers. Optional
route.meta.response = {
status: 200,
headers: {
'X-Foo': 'Bar'
}
};
// Returned data will be passed to component props
return {
ssrState,
childState
};
}
</script>
<script>
export let page;
export let ssrState;
export let childState;
// Initialize data from SSR state.
let data = ssrState;
$: onPageChange(page);
async function onPageChange(page) {
// SSR state will be set to undefined when history.pushState / history.replaceState / popstate event is called.
if (!ssrState) {
data = await fetchData(page, getCookie('token'));
}
}
</script>
{#if data}
<div>{data.content}</div>
<Child foo={data.foo} {...childState} />
{/if}
Constructor
import { Router } from 'svelte-pilot';
const router = new Router({
routes,
base,
pathQuery,
mode
});
routes
RouterViewDefGroup
. Required. Define the routes.
TypeScript definitions:
import { SvelteComponent } from 'svelte';
type RouterViewDefGroup = Array<RouterViewDef | RouterViewDef[]>;
type RouterViewDef = {
name?: string,
path?: string,
component?: SyncComponent | AsyncComponent,
props?: RouteProps,
key?: KeyFn,
meta?: RouteProps,
children?: RouterViewDefGroup,
beforeEnter?: GuardHook,
beforeLeave?: GuardHook
};
type SyncComponent = ComponentModule | typeof SvelteComponent;
type AsyncComponent = () => Promise<SyncComponent>;
type RouteProps = SerializableObject | ((route: Route) => SerializableObject);
type KeyFn = (route: Route) => PrimitiveType;
type GuardHook = (to: Route, from?: Route) => GuardHookResult | Promise<GuardHookResult>;
type ComponentModule = {
default: typeof SvelteComponent,
load?: LoadFn,
beforeEnter?: GuardHook
};
type LoadFn = (props: Record<string, any>, route: Route, ssrContext?: unknown) => Promise<SerializableObject>;
type GuardHookResult = void | boolean | string | Location;
type Location = {
path: string,
params?: Record<string, string | number | boolean>,
query?: Query,
hash?: string,
state?: SerializableObject
};
type Query = Record<string, PrimitiveType | PrimitiveType[]> | URLSearchParams;
type PrimitiveType = string | number | boolean | null | undefined;
type SerializableObject = { [name: string]: PrimitiveType | PrimitiveType[] | { [name: string]: SerializableObject } };
Simple routes
import Foo from './views/Foo.svelte';
import Bar from './views/Bar.svelte';
const routes = [
{
path: '/foo',
component: Foo
},
{
path: '/bar',
component: Bar
}
];
Dynamic import Svelte component
const routes = [
{
path: '/foo',
component: () => import('./views/Foo.svelte')
}
];
Pass props to Svelte component
const routes = [
{
path: '/foo',
component: () => import('./views/Foo.svelte'),
props: { page: 1 }
}
];
Props from query string
props
can be a function that returns a plain object. Its parameter is a Route object.
const routes = [
{
path: '/foo',
component: () => import('./views/Foo.svelte'),
props: route => ({ page: route.query.int('page') })
}
];
Props from path params
const routes = [
{
path: '/people/:username/:year(\\d+)-:month(\\d+)/:articleId(\\d+)',
component: () => import('./views/User.svelte'),
props: route => ({
username: route.params.string('username'),
year: route.params.int('year'),
month: route.params.int('month'),
articleId: route.params.int('articleId')
})
}
];
If regex is omitted, it defaults to [^/]+
.
Catch-all route
const routes = [
{
path: '(.*)', // param name can be omitted
component: () => import('./views/NotFound.svelte')
}
];
Nested routes
const routes = [
{
component: () => import('./views/Root.svelte'),
children: [
{
path: '/foo',
component: () => import('./views/Foo.svelte')
},
{
component: () => import('./views/LayoutA.svelte'),
// children alse can have children.
// '/bar` page will be rendered as:
// <Root>
// <LayoutA>
// <Bar>
// </LayoutA>
// </Root>
children: [
{
path: '/bar',
component() => import('./views/Bar.svelte')
}
]
}
]
}
]
Root.svelte
:
<script>
import { RouterView } from 'svelte-pilot';
</script>
<nav>My beautiful navigation bar</nav>
<main>
<!-- Nested route component will be injected here -->
<RouterView />
</main>
<footer>My footer</footer>
RouterView
s
Multiple const routes = [
{
component: () => import('./views/Root.svelte'),
children: [
{
name: 'aside', // <---- pairs with <RouterView name="aside" />
component: () => import('./views/Aside.svelte')
},
{
path: '/foo',
component: () => import('./views/Foo.svelte')
}
]
}
]
Root.svelte
:
<script>
import { RouterView } from 'svelte-pilot';
</script>
<aside>
<RouterView name="aside" />
</aside>
<main>
<RouterView />
</main>
RouterView
s
Override const routes = [
{
component: () => import('./views/Root.svelte'),
children: [
{
name: 'aside',
component: () => import('./views/AsideA.svelte')
},
{
path: '/foo',
component: () => import('./views/Foo.svelte')
},
// use array to group AsideB and Bar,
// then when rendering '/bar' page, AsideB will be used.
[
{
name: 'aside',
component: () => import('./views/AsideB.svelte')
},
{
path: '/bar'
component: () => import('./views/Bar.svelte')
}
]
]
}
]
RouterView
s
Share meta data between const routes = [
{
component: () => import('./views/Root.svelte'),
props: route => ({ active: route.meta.active }),
meta: { theme: 'dark' },
children: [
{
path: '/foo',
component: () => import('./views/Foo.svelte'),
meta: { active: 'foo' }
},
{
path: '/bar',
component: () => import('./views/Bar.svelte'),
props: route => ({ theme: route.meta.theme }),
meta: route => ({ active: route.query.string('active') })
}
]
}
]
meta
can be a plain object, or a function that receives a Route object as argument and returns a plain object.
All meta objects will be merged together. If objects have a property with the same name, the nested RouterView's meta property will overwrite the outer ones.
Force re-rendering
By default, when component is the same when route changes, the component will not re-rendered.
You can force it to re-render by setting a key
generator:
const routes = [
{
path: '/articles/:id',
component: () => import('./views/Article.svelte'),
key: route => route.query.string('id')
}
];
beforeEnter
guard hook
const routes = [
{
path: '/foo',
beforeEnter: (to, from) => {
return hasLoggedIn ? true : '/login';
}
}
];
When the navigation is triggered,
the beforeEnter
hook functions in the incoming RouterView configs will be triggered.
Params:
Returns:
undefined
ortrue
: Allow the navigation.false
: Abort the navigation and rollback.- path string or Location object: redirect to it.
beforeLeave
guard hook
const routes = [
{
path: '/foo',
beforeLeave: (to, from) => {
// ...
}
}
]
When the route is going to be changed,
the beforeLeave
hook functions in the current RouterView configs will be triggered.
The params and return value is the same as beforeEnter
hook.
base
string
. Optional. The base path of the app.
If your application is serving under https://www.example.com/app/
, then the base
is /app/
.
If you want the root path not end with slash, you can set the base without ending slash, like /app
.
Defaults to /
.
Note, when using router.push()
, router.replace()
and router.handle()
, Only if you pass an absolute URL (starting with protocol),
The base part will be trimmed when matching the route.
const router = new Router({
base: '/app',
routes: [
{
path: '/bar',
component: () => import('./Bar.svelte')
}
]
});
// works
router.handle('http://127.0.0.1/app/bar');
// won't work
router.handler('/app/bar');
pathQuery
string
. Optional. Uses the query name as router path.
It is useful when serving the application under file:
protocol.
e.g.
const router = new Router({
pathQuery: '__path__'
});
file:///path/to/index.html?__path__=/home
will route to /home
.
mode
'server'
| 'client'
. Optional. Defines the running mode.
If not set, it will auto detect by typeof window === 'object' ? 'client' : 'server'
.
Route object
A route object contains these information of the matched route:
{
path,
params,
search,
query,
hash,
href,
state,
meta
}
path
string
. A string containing an initial '/'
followed by the path of the URL not including the query string or fragment.
params
StringCaster
. A StringCaster object that wraps the path params.
search
string
. A string containing a '?'
followed by the parameters of the URL.
query
StringCaster
. A StringCaster object that wraps the URLSearchParams
object.
hash
string
. A string containing a '#'
followed by the fragment identifier of the URL.
href
string
. The relative URL of the route.
state
object
. history.state.
meta
object
. A plain object used to share information between RouterView configs.
Location object
A Location object is used to describe the destination of a navigation. It is made up by the following properties:
{
path,
params,
query,
hash,
state
}
path
string
. A string containing an initial '/'
followed by the path of the URL.
It can include query string and hash but not recommended, because you need to handle non-ASCII chars manually.
params
object
. A key-value object to fill into the param placeholder of path
.
For example, { path: '/articles/:id', params: { id: 123 } }
is equal to { path: '/articles/123 }
.
It is safer to use params instead of concatting strings by hand,
because encodeURIComponent()
is applied on the param value for you.
query
object
| URLSearchParams
.
If a plain object is provided,
its format is the same as the return value of the querystring
module's parse() method:
{
foo: 'bar',
abc: ['xyz', '123']
}
hash
string
. A string containing a '#'
followed by the fragment identifier of the URL.
state
object
. The state object of the location.
<RouterLink>
A navigation component. It renders an <a>
element.
<script>
import { RouterLink } from 'svelte-pilot';
</script>
<RouterLink to={loaction} replace={true} style="color: red;" class="cls-name">Link</RouterLink>
Props
to
: Location |string
.replace
:Boolean
. Defaults tofalse
. If true, the navigation will be handled byhistory.replaceState()
.style
:string
. Set thestyle
attribute of<a>
tag.class
:string
. Set theclass
attribute of<a>
tag.
<RouterLink>
always has router-link
class.
If the location equals to the current route's path, router-link-active
class will be on.
Properties
router.current
The current Route. It is only available in client
mode.
Methods
router.handle()
router.handle(location, ssrContext)
Manually handle the route. Used in server
mode. See Server-Side Rendering for usage.
Params
- location: Locaton or path string.
- ssrContext:
any
. It will be passed to theload
function.
Returns
An object contains:
{
route,
ssrState
}
route
: Route object.ssrState
: A serializable object. It is used to inject into the html for hydration by the client side.
router.parseLocation()
router.parseLocation(location: Location | string)
Parse the Location object or path string, and return a subset of Route object:
{
path,
query,
search,
hash,
state,
href
}
router.href()
router.href(location: Location | string)
Returns the href of Location object of path string. It can be used as href
attribute of <a>
tag.
router.push()
router.push(location: Location | string)
Change the route by calling history.pushState()
.
router.replace()
router.replace(location: Location | string)
Replace the current route by calling history.replaceState()
.
router.setState()
router.setState(state)
Merge the state into the current route's state.
router.go()
router.go(position, state)
Works like history.go()
, but has an additional state
parameter.
If State
is set, It will be merged into the state object of the destination location.
router.back()
router.back(state)
Alias of router.go(-1, state)
router.forward()
router.forward(state)
Alias of router.go(1, state)
router.on()
Add a hook function that will be called when the specified event fires.
router.on('beforeChange', hook: GuardHook)
router.on('beforeCurrentRouteLeave', hook: GuardHook)
router.on('update', hook: UpdateHook)
router.on('afterChange', hook: NormalHook)
type GuardHook = (to: Route, from?: Route) => GuardHookResult | Promise<GuardHookResult>;
type GuardHookResult = void | boolean | string | Location;
type UpdateHook = (route: Route) => void;
type NormalHook = (to: Route, from?: Route) => void;
Hooks running order
- Navigation triggered.
- Call
beforeLeave
hooks in the outgoingRouterView
configs. - Call
beforeCurrentRouteLeave
hooks registered viarouter.on('beforeCurrentRouteLeave', hook)
. - Call
beforeChange
hooks registered viarouter.on('beforeChange', hook)
. - Call
beforeEnter
hooks in the incomingRouterView
configs andbeforeEnter
hooks exported from context module (<script context="module">
) of aync Svelte component. - Call
beforeEnter
hooks exported from context module of async Svelte component. - Call
update
hooks registered viarouter.on('update', hook)
. - Call
afterChange
hooks registered viarouter.on('afterChange', hook)
.
router.off()
router.off(event, hook)
Removes the specified event hook.
router.once()
Run a hook function once.
Get current route and router instance in components
<script>
import { getContext } from 'svelte';
const router = getContext('__SVELTE_PILOT_ROUTER__');
router.push('/foo');
const routeStore = getContext('__SVELTE_PILOT_ROUTE__');
console.log($routeStore.path);
</script>