Skip to content
Draft
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
2 changes: 1 addition & 1 deletion examples/web-todomvc-redwood/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ Run the smoke test locally with `pnpm --filter livestore-example-web-todomvc-red

## To-do

- [ ] Make SSR work properly with LiveStore initialization
- [x] Make SSR work properly with LiveStore initialization
8 changes: 8 additions & 0 deletions examples/web-todomvc-redwood/src/ambient.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1,9 @@
/// <reference types="vite/client" />

import type { WebAdapterSsrEncodedSnapshot } from '@livestore/adapter-web'

declare global {
interface Window {
__LIVESTORE_SSR__?: Record<string, WebAdapterSsrEncodedSnapshot>
}
}
16 changes: 14 additions & 2 deletions examples/web-todomvc-redwood/src/app/Document.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import type React from 'react'
import type { RequestInfo } from 'rwsdk/worker'

export const Document: React.FC<{ children: React.ReactNode }> = ({ children }) => (
type DocumentProps = React.PropsWithChildren<RequestInfo>

export const Document: React.FC<DocumentProps> = ({ children, rw }) => (
<html lang="en">
<head>
<meta charSet="utf-8" />
Expand All @@ -11,7 +14,16 @@ export const Document: React.FC<{ children: React.ReactNode }> = ({ children })
<body>
{/* biome-ignore lint/correctness/useUniqueElementIds: Redwood hydrates client markup at a fixed root id */}
<div id="root">{children}</div>
<script type="module">import("/src/client.tsx")</script>
{[...rw.entryScripts].map((src) => (
<script key={src} type="module" src={src} nonce={rw.nonce} />
))}
{[...rw.inlineScripts].map((content) => (
// biome-ignore lint/security/noDangerouslySetInnerHtml: Content is generated server-side and protected by the request nonce.
<script key={content} nonce={rw.nonce} dangerouslySetInnerHTML={{ __html: content }} />
))}
<script type="module" nonce={rw.nonce}>
import('/src/client.tsx')
</script>
</body>
</html>
)
23 changes: 22 additions & 1 deletion examples/web-todomvc-redwood/src/app/pages/Home.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,26 @@
import { createWebAdapterSsrSnapshot, encodeWebAdapterSsrSnapshot } from '@livestore/adapter-web'

import { getRequestInfo } from 'rwsdk/worker'
import { schema } from '../todomvc/livestore/schema.js'
import { TodoApp } from '../todomvc/TodoApp.js'

export const Home = () => {
export const Home = async () => {
const requestInfo = getRequestInfo()

if (requestInfo.rw.ssr) {
try {
const snapshot = await createWebAdapterSsrSnapshot({ schema })
const encodedSnapshot = encodeWebAdapterSsrSnapshot(snapshot)

requestInfo.rw.inlineScripts.add(
`window.__LIVESTORE_SSR__=window.__LIVESTORE_SSR__??{};window.__LIVESTORE_SSR__['${snapshot.storeId}']=${JSON.stringify(
encodedSnapshot,
)};`,
)
} catch (error) {
console.error('[TodoMVC] Failed to prepare LiveStore SSR snapshot', error)
}
}

return <TodoApp />
}
12 changes: 12 additions & 0 deletions examples/web-todomvc-redwood/src/app/todomvc/TodoApp.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,18 @@ const adapter = makePersistedAdapter({
worker: LiveStoreWorker,
sharedWorker: LiveStoreSharedWorker,
resetPersistence,
ssr: {
initialData: ({ storeId }) => (typeof window === 'undefined' ? undefined : window.__LIVESTORE_SSR__?.[storeId]),
onConsume: (data) => {
if (typeof window === 'undefined') {
return
}

if (window.__LIVESTORE_SSR__ !== undefined) {
delete window.__LIVESTORE_SSR__[data.storeId]
}
},
},
})

export const TodoApp: React.FC = () => (
Expand Down
8 changes: 8 additions & 0 deletions packages/@livestore/adapter-web/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
export { makeInMemoryAdapter } from './in-memory/in-memory-adapter.ts'
export {
type CreateWebAdapterSsrSnapshotOptions,
createWebAdapterSsrSnapshot,
decodeWebAdapterSsrSnapshot,
encodeWebAdapterSsrSnapshot,
type WebAdapterSsrEncodedSnapshot,
type WebAdapterSsrSnapshot,
} from './ssr.ts'
export { makePersistedAdapter, type WebAdapterOptions } from './web-worker/client-session/persisted-adapter.ts'
export * as WorkerSchema from './web-worker/common/worker-schema.ts'
120 changes: 120 additions & 0 deletions packages/@livestore/adapter-web/src/ssr.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { makeAdapter as makeNodeAdapter } from '@livestore/adapter-node'

Check failure on line 1 in packages/@livestore/adapter-web/src/ssr.ts

View workflow job for this annotation

GitHub Actions / test-unit

src/useQuery.test.tsx

Error: Cannot find package '@livestore/adapter-node' imported from '/home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts' ❯ ../adapter-web/src/ssr.ts:1:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' } Caused by: Error: Failed to load url @livestore/adapter-node (resolved id: @livestore/adapter-node) in /home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts. Does the file exist? ❯ loadAndTransform ../../../node_modules/.pnpm/vite@7.1.7_@types+node@24.5.2_jiti@2.5.1_lightningcss@1.30.1_terser@5.44.0_tsx@4.20.5_yaml@2.8.1/node_modules/vite/dist/node/chunks/dep-Bm2ujbhY.js:26109:33

Check failure on line 1 in packages/@livestore/adapter-web/src/ssr.ts

View workflow job for this annotation

GitHub Actions / test-unit

src/useClientDocument.test.tsx

Error: Cannot find package '@livestore/adapter-node' imported from '/home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts' ❯ ../adapter-web/src/ssr.ts:1:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' } Caused by: Error: Failed to load url @livestore/adapter-node (resolved id: @livestore/adapter-node) in /home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts. Does the file exist? ❯ loadAndTransform ../../../node_modules/.pnpm/vite@7.1.7_@types+node@24.5.2_jiti@2.5.1_lightningcss@1.30.1_terser@5.44.0_tsx@4.20.5_yaml@2.8.1/node_modules/vite/dist/node/chunks/dep-Bm2ujbhY.js:26109:33

Check failure on line 1 in packages/@livestore/adapter-web/src/ssr.ts

View workflow job for this annotation

GitHub Actions / test-unit

src/LiveStoreProvider.test.tsx

Error: Cannot find package '@livestore/adapter-node' imported from '/home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts' ❯ ../adapter-web/src/ssr.ts:1:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' } Caused by: Caused by: Error: Failed to load url @livestore/adapter-node (resolved id: @livestore/adapter-node) in /home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts. Does the file exist? ❯ loadAndTransform ../../../node_modules/.pnpm/vite@7.1.7_@types+node@24.5.2_jiti@2.5.1_lightningcss@1.30.1_terser@5.44.0_tsx@4.20.5_yaml@2.8.1/node_modules/vite/dist/node/chunks/dep-Bm2ujbhY.js:26109:33

Check failure on line 1 in packages/@livestore/adapter-web/src/ssr.ts

View workflow job for this annotation

GitHub Actions / test-unit

src/live-queries/signal.test.ts

Error: Cannot find package '@livestore/adapter-node' imported from '/home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts' ❯ ../adapter-web/src/ssr.ts:1:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' } Caused by: Error: Failed to load url @livestore/adapter-node (resolved id: @livestore/adapter-node) in /home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts. Does the file exist? ❯ loadAndTransform ../../../node_modules/.pnpm/vite@7.1.7_@types+node@24.5.2_jiti@2.5.1_lightningcss@1.30.1_terser@5.44.0_tsx@4.20.5_yaml@2.8.1/node_modules/vite/dist/node/chunks/dep-Bm2ujbhY.js:26109:33

Check failure on line 1 in packages/@livestore/adapter-web/src/ssr.ts

View workflow job for this annotation

GitHub Actions / test-unit

src/live-queries/db-query.test.ts

Error: Cannot find package '@livestore/adapter-node' imported from '/home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts' ❯ ../adapter-web/src/ssr.ts:1:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' } Caused by: Error: Failed to load url @livestore/adapter-node (resolved id: @livestore/adapter-node) in /home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts. Does the file exist? ❯ loadAndTransform ../../../node_modules/.pnpm/vite@7.1.7_@types+node@24.5.2_jiti@2.5.1_lightningcss@1.30.1_terser@5.44.0_tsx@4.20.5_yaml@2.8.1/node_modules/vite/dist/node/chunks/dep-Bm2ujbhY.js:26109:33

Check failure on line 1 in packages/@livestore/adapter-web/src/ssr.ts

View workflow job for this annotation

GitHub Actions / test-unit

src/SqliteDbWrapper.test.ts

Error: Cannot find package '@livestore/adapter-node' imported from '/home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts' ❯ ../adapter-web/src/ssr.ts:1:1 ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯ Serialized Error: { code: 'ERR_MODULE_NOT_FOUND' } Caused by: Caused by: Error: Failed to load url @livestore/adapter-node (resolved id: @livestore/adapter-node) in /home/runner/work/livestore/livestore/packages/@livestore/adapter-web/src/ssr.ts. Does the file exist? ❯ loadAndTransform ../../../node_modules/.pnpm/vite@7.1.7_@types+node@24.5.2_jiti@2.5.1_lightningcss@1.30.1_terser@5.44.0_tsx@4.20.5_yaml@2.8.1/node_modules/vite/dist/node/chunks/dep-Bm2ujbhY.js:26109:33
import type { SyncOptions } from '@livestore/common'
import { liveStoreVersion, type MigrationsReport } from '@livestore/common'
import type { LiveStoreSchema } from '@livestore/common/schema'
import { createStorePromise } from '@livestore/livestore'
import { omitUndefineds } from '@livestore/utils'
import type { Schema } from '@livestore/utils/effect'

const DEFAULT_INITIAL_SYNC_TIMEOUT = 5_000

export interface WebAdapterSsrSnapshot {
storeId: string
snapshot: Uint8Array<ArrayBuffer>
migrationsReport: MigrationsReport
liveStoreVersion: string
}

export interface WebAdapterSsrEncodedSnapshot {
storeId: string
snapshotBase64: string
migrationsReport: MigrationsReport
liveStoreVersion: string
}

export interface CreateWebAdapterSsrSnapshotOptions {
schema: LiveStoreSchema
storeId?: string
sync?: SyncOptions
syncPayload?: Schema.JsonValue
storage?: Parameters<typeof makeNodeAdapter>[0]['storage']
}

export const createWebAdapterSsrSnapshot = async ({
schema,
storeId = 'default',
sync,
syncPayload,
storage,
}: CreateWebAdapterSsrSnapshotOptions): Promise<WebAdapterSsrSnapshot> => {
const adapter = makeNodeAdapter({
storage: storage ?? { type: 'in-memory' },
...omitUndefineds({
sync:
sync ??
({
initialSyncOptions: { _tag: 'Blocking' as const, timeout: DEFAULT_INITIAL_SYNC_TIMEOUT },
} satisfies SyncOptions),
}),
})

const store = await createStorePromise({
schema,
storeId,
adapter,
disableDevtools: true,
batchUpdates: (run) => run(),
syncPayload,
})

try {
return {
storeId,
snapshot: store.sqliteDbWrapper.export(),
migrationsReport: store.clientSession.leaderThread.initialState.migrationsReport,
liveStoreVersion,
}
} finally {
await store.shutdownPromise().catch(() => {})
}
}

export const encodeWebAdapterSsrSnapshot = (snapshot: WebAdapterSsrSnapshot): WebAdapterSsrEncodedSnapshot => ({
storeId: snapshot.storeId,
snapshotBase64: encodeBase64(snapshot.snapshot),
migrationsReport: snapshot.migrationsReport,
liveStoreVersion: snapshot.liveStoreVersion,
})

export const decodeWebAdapterSsrSnapshot = (encoded: WebAdapterSsrEncodedSnapshot): WebAdapterSsrSnapshot => ({
storeId: encoded.storeId,
snapshot: decodeBase64(encoded.snapshotBase64),
migrationsReport: encoded.migrationsReport,
liveStoreVersion: encoded.liveStoreVersion,
})

const encodeBase64 = (bytes: Uint8Array<ArrayBuffer>): string => {
if (typeof Buffer !== 'undefined') {
return Buffer.from(bytes).toString('base64')
}

if (typeof globalThis.btoa === 'function') {
let binary = ''
for (const byte of bytes) {
binary += String.fromCharCode(byte)
}

return globalThis.btoa(binary)
}

throw new Error('Base64 encoding is not supported in this environment')
}

const decodeBase64 = (value: string): Uint8Array<ArrayBuffer> => {
if (typeof Buffer !== 'undefined') {
return new Uint8Array(Buffer.from(value, 'base64'))
}

if (typeof globalThis.atob === 'function') {
const binary = globalThis.atob(value)

const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}

return bytes
}

throw new Error('Base64 decoding is not supported in this environment')
}
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ import {
import { nanoid } from '@livestore/utils/nanoid'

import * as OpfsUtils from '../../opfs-utils.ts'
import type { WebAdapterSsrEncodedSnapshot, WebAdapterSsrSnapshot } from '../../ssr.ts'
import { decodeWebAdapterSsrSnapshot } from '../../ssr.ts'
import { readPersistedAppDbFromClientSession, resetPersistedDataFromClientSession } from '../common/persisted-sqlite.ts'
import { makeShutdownChannel } from '../common/shutdown-channel.ts'
import { DedicatedWorkerDisconnectBroadcast, makeWorkerDisconnectChannel } from '../common/worker-disconnect-channel.ts'
Expand Down Expand Up @@ -115,6 +117,14 @@ export type WebAdapterOptions = {
*/
awaitSharedWorkerTermination?: boolean
}
ssr?: WebAdapterSsrOptions
}

export type WebAdapterSsrInitialData = WebAdapterSsrSnapshot | WebAdapterSsrEncodedSnapshot

export interface WebAdapterSsrOptions {
initialData?: WebAdapterSsrInitialData | ((context: { storeId: string }) => WebAdapterSsrInitialData | undefined)
onConsume?: (data: WebAdapterSsrInitialData) => void
}

/**
Expand Down Expand Up @@ -175,16 +185,19 @@ export const makePersistedAdapter =
yield* resetPersistedDataFromClientSession({ storageOptions, storeId })
}

const ssrInitialData = yield* resolveSsrInitialData({ options: options.ssr, storeId })

// Note on fast-path booting:
// Instead of waiting for the leader worker to boot and then get a database snapshot from it,
// we're here trying to get the snapshot directly from storage
// we usually speeds up the boot process by a lot.
// We need to be extra careful though to not run into any race conditions or inconsistencies.
// TODO also verify persisted data
const dataFromFile =
options.experimental?.disableFastPath === true
ssrInitialData?.normalized.snapshot ??
(options.experimental?.disableFastPath === true
? undefined
: yield* readPersistedAppDbFromClientSession({ storageOptions, storeId, schema })
: yield* readPersistedAppDbFromClientSession({ storageOptions, storeId, schema }))

// The same across all client sessions (i.e. tabs, windows)
const clientId = options.clientId ?? getPersistedId(`clientId:${storeId}`, 'local')
Expand Down Expand Up @@ -382,18 +395,36 @@ export const makePersistedAdapter =
// TODO maybe bring back transfering the initially created in-memory db snapshot instead of
// re-exporting the db
const initialResult =
dataFromFile === undefined
? yield* runInWorker(new WorkerSchema.LeaderWorkerInnerGetRecreateSnapshot()).pipe(
Effect.map(({ snapshot, migrationsReport }) => ({
_tag: 'from-leader-worker' as const,
snapshot,
migrationsReport,
})),
)
: { _tag: 'fast-path' as const, snapshot: dataFromFile }
ssrInitialData !== undefined
? ({
_tag: 'from-ssr' as const,
snapshot: ssrInitialData.normalized.snapshot,
migrationsReport: ssrInitialData.normalized.migrationsReport,
} satisfies {
_tag: 'from-ssr'
snapshot: Uint8Array<ArrayBuffer>
migrationsReport: typeof ssrInitialData.normalized.migrationsReport
})
: dataFromFile === undefined
? yield* runInWorker(new WorkerSchema.LeaderWorkerInnerGetRecreateSnapshot()).pipe(
Effect.map(({ snapshot, migrationsReport }) => ({
_tag: 'from-leader-worker' as const,
snapshot,
migrationsReport,
})),
)
: { _tag: 'fast-path' as const, snapshot: dataFromFile }

if (initialResult._tag === 'from-ssr' && ssrInitialData !== undefined) {
options.ssr?.onConsume?.(ssrInitialData.raw)
}

const migrationsReport =
initialResult._tag === 'from-leader-worker' ? initialResult.migrationsReport : { migrations: [] }
initialResult._tag === 'from-leader-worker'
? initialResult.migrationsReport
: initialResult._tag === 'from-ssr'
? initialResult.migrationsReport
: { migrations: [] }

const makeSqliteDb = sqliteDbFactory({ sqlite3 })
const sqliteDb = yield* makeSqliteDb({ _tag: 'in-memory' })
Expand Down Expand Up @@ -572,3 +603,35 @@ const ensureBrowserRequirements = Effect.gen(function* () {
validate(typeof sessionStorage === 'undefined', 'sessionStorage'),
])
})

const resolveSsrInitialData = ({ options, storeId }: { options: WebAdapterSsrOptions | undefined; storeId: string }) =>
Effect.gen(function* () {
if (options?.initialData === undefined) {
return undefined
}

const initialData =
typeof options.initialData === 'function' ? options.initialData({ storeId }) : options.initialData

if (initialData === undefined) {
return undefined
}

const normalized = 'snapshotBase64' in initialData ? decodeWebAdapterSsrSnapshot(initialData) : initialData

if (normalized.storeId !== storeId) {
yield* Effect.logWarning(
`[@livestore/adapter-web:ssr] Ignoring SSR payload for store '${normalized.storeId}' (expected '${storeId}')`,
)
return undefined
}

if (normalized.liveStoreVersion !== liveStoreVersion) {
yield* Effect.logWarning(
`[@livestore/adapter-web:ssr] Ignoring SSR payload built for LiveStore v${normalized.liveStoreVersion} (expected v${liveStoreVersion})`,
)
return undefined
}

return { normalized, raw: initialData }
})
Loading