svelte-pilot

A svelte router with SSR support

Usage no npm install needed!

<script type="module">
  import sveltePilot from 'https://cdn.skypack.dev/svelte-pilot';
</script>

README

svelte-pilot

A svelte router with SSR (Server-Side Rendering) support.

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 to router.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>

Multiple RouterViews

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>

Override RouterViews

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')
        }
      ]
    ]
  }
]

Share meta data between RouterViews

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:
  • to: The Route object will be changed to.
  • from: The current Route object.
Returns:
  • undefined or true: 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 to false. If true, the navigation will be handled by history.replaceState().
  • style: string. Set the style attribute of <a> tag.
  • class: string. Set the class 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. I will be passed to the load 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

  1. Navigation triggered.
  2. Call beforeLeave hooks in the outgoing RouterView configs.
  3. Call beforeCurrentRouteLeave hooks registered via router.on('beforeCurrentRouteLeave', hook).
  4. Call beforeChange hooks registered via router.on('beforeChange', hook).
  5. Call beforeEnter hooks in the incoming RouterView configs and beforeEnter hooks exported from context module (<script context="module">) of aync Svelte component.
  6. Call beforeEnter hooks exported from context module of async Svelte component.
  7. Call update hooks registered via router.on('update', hook).
  8. Call afterChange hooks registered via router.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>

License

MIT