Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion api/http/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { SUBPROTOCOL } from '../index.ts'

export function isObject(body: unknown): body is object {
return typeof body === 'object' && body !== null
}
Expand Down Expand Up @@ -54,10 +56,12 @@ export async function fetchJSON<ResponseJSON = unknown>(
body: JSON.stringify(body),
credentials: 'include',
headers: {
'Content-Type': 'application/json'
'Content-Type': 'application/json',
'X-Subprotocol': String(SUBPROTOCOL)
},
method
})

if (opts.response) opts.response(response)
return response as HTTPResponse<ResponseJSON>
}
Expand Down
1 change: 1 addition & 0 deletions api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Client’s protocol version
*/
export const SUBPROTOCOL = 0
export const SUBPROTOCOL_ERROR_MESSAGE = 'Outdated client'

/**
* Project’s version to use in UI.
Expand Down
9 changes: 9 additions & 0 deletions core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ function getServer(): ClientOptions['server'] {

let prevClient: CrossTabClient | undefined
export const client = atom<CrossTabClient | undefined>()
export const isOutdatedClient = atom<boolean>(false)

onEnvironment(({ logStoreCreator }) => {
let unbindUser = effect(
Expand All @@ -71,6 +72,14 @@ onEnvironment(({ logStoreCreator }) => {
encryptActions(logux, key, {
ignore: [deleteUser.type]
})

/* node:coverage disable */
logux.node.on('error', error => {
if (error.type === 'wrong-subprotocol') {
isOutdatedClient.set(true)
}
})
/* node:coverage enable */
logux.start(connect)
prevClient = logux
client.set(logux)
Expand Down
6 changes: 5 additions & 1 deletion core/lib/http.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { Requester } from '@slowreader/api'
import { type Requester, SUBPROTOCOL_ERROR_MESSAGE } from '@slowreader/api'

import { getEnvironment } from '../environment.ts'
import {
detectNetworkError,
HTTPStatusError,
UserFacingError
} from '../errors.ts'
import { isOutdatedClient } from '../index.ts'

/**
* Takes fetch() wrapper from `@slowreader/api/http` and do the request
Expand All @@ -31,6 +32,9 @@ export async function checkErrors<Params extends object, ResponseJSON>(
if (!response.ok) {
let text = await response.text()
if (response.status === 400 && text !== 'Invalid request') {
if (text === SUBPROTOCOL_ERROR_MESSAGE) {
isOutdatedClient.set(true)
}
throw new UserFacingError(text)
} else {
throw new HTTPStatusError(
Expand Down
1 change: 1 addition & 0 deletions core/messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,5 @@ export * from './post/en.ts'
export * from './profile/en.ts'
export * from './refresh/en.ts'
export * from './settings/en.ts'
export * from './update-client/en.ts'
export * from './welcome/en.ts'
8 changes: 8 additions & 0 deletions core/messages/update-client/en.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { i18n } from '../../i18n.ts'

export const updateClientMessages = i18n('updateClient', {
pageText:
'You’re using an outdated client version. Please update the app to continue.',
pageTitle: 'Update Your App',
updateButton: 'Update Now'
})
3 changes: 3 additions & 0 deletions core/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { importPage } from './import.ts'
import { profilePage } from './profile.ts'
import { signupPage } from './signup.ts'
import { startPage } from './start.ts'
import { updateClientPage } from './update-client.ts'

export type { AboutPage } from './about.ts'
export type { AddPage } from './add.ts'
Expand All @@ -26,6 +27,7 @@ export type { ImportPage } from './import.ts'
export type { ProfilePage } from './profile.ts'
export type { SignupPage } from './signup.ts'
export type { StartPage } from './start.ts'
export type { UpdateClientPage } from './update-client.ts'

export const pages = {
about: aboutPage,
Expand All @@ -45,6 +47,7 @@ export const pages = {
signup: signupPage,
slow: slowPage,
start: startPage,
updateClient: updateClientPage,
welcome: createSimplePage('welcome')
} satisfies {
[Name in RouteName]: Name extends 'fast' | 'slow'
Expand Down
17 changes: 17 additions & 0 deletions core/pages/update-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createPage } from './common.ts'

export const updateClientPage = createPage('updateClient', () => {
/* node:coverage disable */
function handleUpdateClient(): void {
location.reload()
}
/* node:coverage enable */

return {
exit() {},
handleUpdateClient,
params: {}
}
})

export type UpdateClientPage = ReturnType<typeof updateClientPage>
1 change: 1 addition & 0 deletions core/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface Routes {
from?: number
}
start: {}
updateClient: {}
welcome: {}
}

Expand Down
28 changes: 28 additions & 0 deletions core/test/pages/update-client.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { equal } from 'node:assert'
import { afterEach, beforeEach, test } from 'node:test'

import { currentPage } from '../../index.ts'
import {
cleanClientTest,
enableClientTest,
openPage,
setBaseTestRoute
} from '../utils.ts'

beforeEach(() => {
enableClientTest()
setBaseTestRoute({ params: {}, route: 'updateClient' })
})

afterEach(async () => {
await cleanClientTest()
})

test('shows update client page', () => {
let page = openPage({
params: {},
route: 'updateClient'
})

equal(currentPage.get().route, page.route)
})
16 changes: 14 additions & 2 deletions server/lib/http.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { BaseServer } from '@logux/server'
import type { Endpoint } from '@slowreader/api'
import { type Endpoint, SUBPROTOCOL_ERROR_MESSAGE } from '@slowreader/api'
import type { IncomingMessage, ServerResponse } from 'node:http'

import { config } from './config.ts'
Expand Down Expand Up @@ -37,7 +37,7 @@ function allowCors(res: ServerResponse, origin: string): void {
'Access-Control-Allow-Methods',
'OPTIONS, POST, GET, PUT, DELETE'
)
res.setHeader('Access-Control-Allow-Headers', 'Content-Type')
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Subprotocol')
}

const LOCALHOST = /:\/\/localhost:/
Expand Down Expand Up @@ -67,6 +67,18 @@ export function jsonApi<Response, Request extends object>(
allowCors(res, req.headers.origin)
}
}

if (req.headers['x-subprotocol'] && server.options.minSubprotocol) {
let clientSubprotocol = Number(req.headers['x-subprotocol'])

if (
isNaN(clientSubprotocol) ||
clientSubprotocol < server.options.minSubprotocol
) {
return badRequest(res, SUBPROTOCOL_ERROR_MESSAGE)
}
}

if (req.method === 'OPTIONS') {
res.writeHead(200)
res.end()
Expand Down
19 changes: 18 additions & 1 deletion server/test/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ import {
setPassword,
signIn,
signOut,
signUp
signUp,
SUBPROTOCOL_ERROR_MESSAGE
} from '@slowreader/api'
import { eq } from 'drizzle-orm'
import { deepEqual, equal, notEqual, ok } from 'node:assert/strict'
Expand Down Expand Up @@ -253,3 +254,19 @@ test('supports CORS', async () => {
'https://dev.slowreader.app'
)
})

test('rejects old clients', async () => {
server = buildTestServer()
server.options.minSubprotocol = 2

let response = await server.fetch('/users/1', {
headers: {
'Content-Type': 'application/json',
'X-Subprotocol': '0'
},
method: 'POST'
})
equal(response.status, 400)
let text = await response.text()
equal(text, SUBPROTOCOL_ERROR_MESSAGE)
})
2 changes: 1 addition & 1 deletion web/.size-limit.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"!dist/assets/playpen-sans-*.woff2",
"!dist/assets/*-{italic,math,latin-ext}-*.woff2"
],
"limit": "185 KB"
"limit": "190 KB"
},
{
"name": "Core scripts to execute",
Expand Down
5 changes: 5 additions & 0 deletions web/main/main.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
import {
busy,
currentPage,
isOutdatedClient,
layoutType,
notFound,
pages,
popupsStatus,
signOut,
subscribeUntil,
Expand All @@ -23,6 +25,7 @@
import ProfilePage from '../pages/profile.svelte'
import SignupPage from '../pages/signup.svelte'
import StartPage from '../pages/start.svelte'
import UpdateClientPage from '../pages/update-client.svelte'
import FeedPopup from '../popups/feed.svelte'
import LoadingPopup from '../popups/loading.svelte'
import NotFoundPopup from '../popups/not-found.svelte'
Expand Down Expand Up @@ -59,6 +62,8 @@
{#if !globalLoader}
<BusyPage />
{/if}
{:else if $isOutdatedClient}
<UpdateClientPage page={pages.updateClient()} />
{:else if $currentPage.route === 'notFound'}
<NotFoundPage />
{:else if $currentPage.route === 'fast'}
Expand Down
34 changes: 34 additions & 0 deletions web/pages/update-client.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<script lang="ts">
import { mdiSyncOff } from '@mdi/js'
import {
updateClientMessages as t,
type UpdateClientPage
} from '@slowreader/core'

import Button from '../ui/button.svelte'
import PageIcon from '../ui/page-icon.svelte'
import Stack from '../ui/stack.svelte'
import ThinPage from '../ui/thin-page.svelte'
import Title from '../ui/title.svelte'

let { page }: { page: UpdateClientPage } = $props()
</script>

<ThinPage align="center" bottomOnMobile={false} title={$t.pageTitle}>
<Stack align="center" gap="xl">
<PageIcon path={mdiSyncOff} />
<Title>{$t.pageTitle}</Title>
<p class="update-client_message">{$t.pageText}</p>
<Button onclick={page.handleUpdateClient} variant="main"
>{$t.updateButton}</Button
>
</Stack>
</ThinPage>

<style>
:global {
.update-client_message {
text-align: center;
}
}
</style>
1 change: 1 addition & 0 deletions web/stores/url-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ export const pathRouter = createRouter({
signup: '/signup',
slow: '/slow/:feed?',
start: '/start',
updateClient: '/update-client',
welcome: '/welcome'
})

Expand Down
18 changes: 18 additions & 0 deletions web/stories/pages/update-client.stories.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script context="module" lang="ts">
import { pages } from '@slowreader/core'
import { defineMeta } from '@storybook/addon-svelte-csf'

import UpdateClientPage from '../../pages/update-client.svelte'
import Scene from '../scene.svelte'

let { Story } = defineMeta({
component: UpdateClientPage,
title: 'Pages/Update Client'
})
</script>

<Story name="Light" asChild parameters={{ layout: 'fullscreen' }}>
<Scene route="updateClient" user={false}>
<UpdateClientPage page={pages.updateClient()} />
</Scene>
</Story>