spacewave 0.0.0 → 0.57.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +82 -0
- package/API.md +364 -0
- package/CHANGELOG.md +11 -0
- package/LICENSE +191 -0
- package/README.md +349 -0
- package/dist/THIRD_PARTY_NOTICES.txt +4497 -0
- package/dist/build.json +6 -0
- package/dist/chunks/errors-BHnASqFk.mjs +2 -0
- package/dist/chunks/node.pb-ChF26ESv.mjs +2 -0
- package/dist/chunks/rolldown-runtime-Csi-cuKc.mjs +1 -0
- package/dist/engine-worker.mjs +112 -0
- package/dist/index.mjs +1 -0
- package/dist/react.mjs +1 -0
- package/dist/server.mjs +10 -0
- package/dist/types/core/sync/config.d.ts +55 -0
- package/dist/types/packages/spacewave/index.d.ts +7 -0
- package/dist/types/packages/spacewave/react.d.ts +1 -0
- package/dist/types/packages/spacewave/server.d.ts +4 -0
- package/dist/types/sdk/sync/access.d.ts +15 -0
- package/dist/types/sdk/sync/client.d.ts +37 -0
- package/dist/types/sdk/sync/errors.d.ts +12 -0
- package/dist/types/sdk/sync/json.d.ts +30 -0
- package/dist/types/sdk/sync/operation.d.ts +23 -0
- package/dist/types/sdk/sync/schema.d.ts +51 -0
- package/dist/types/web/sync/index.d.ts +4 -0
- package/dist/types/web/sync/react.d.ts +11 -0
- package/examples/task-board/package.json +25 -0
- package/examples/task-board/react.html +17 -0
- package/examples/task-board/react.tsx +235 -0
- package/examples/task-board/schema.ts +21 -0
- package/examples/task-board/server.ts +133 -0
- package/examples/task-board/style.css +154 -0
- package/examples/task-board/tsconfig.json +15 -0
- package/examples/task-board/vanilla.html +17 -0
- package/examples/task-board/vanilla.ts +190 -0
- package/package.json +54 -16
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type CollectionAccess, type DatabaseAccess } from './access.js';
|
|
2
|
+
import { SyncError } from './errors.js';
|
|
3
|
+
import type { CallOptions, Output, Query, RecordEntry, Schema, Validator } from './schema.js';
|
|
4
|
+
export interface ConnectionState {
|
|
5
|
+
readonly status: 'ready' | 'reconnecting' | 'closed';
|
|
6
|
+
readonly error?: SyncError;
|
|
7
|
+
}
|
|
8
|
+
export interface SubscriptionState<T> {
|
|
9
|
+
readonly status: 'loading' | 'current' | 'stale' | 'error';
|
|
10
|
+
readonly data: readonly RecordEntry<T>[];
|
|
11
|
+
readonly error?: SyncError;
|
|
12
|
+
}
|
|
13
|
+
export interface Observer<T> {
|
|
14
|
+
next(state: SubscriptionState<T>): void | Promise<void>;
|
|
15
|
+
error?(error: SyncError): void;
|
|
16
|
+
}
|
|
17
|
+
export interface Collection<V extends Validator> extends CollectionAccess<V> {
|
|
18
|
+
watch(query?: Query, options?: CallOptions): AsyncIterable<SubscriptionState<Output<V>>>;
|
|
19
|
+
subscribe(query: Query, observer: Observer<Output<V>>, options?: CallOptions): () => void;
|
|
20
|
+
}
|
|
21
|
+
export interface ConnectionStatus {
|
|
22
|
+
readonly current: ConnectionState;
|
|
23
|
+
subscribe(listener: (state: ConnectionState) => void): () => void;
|
|
24
|
+
}
|
|
25
|
+
export interface Database<S extends Schema> extends DatabaseAccess<S>, AsyncDisposable {
|
|
26
|
+
readonly connection: ConnectionStatus;
|
|
27
|
+
collection<K extends keyof S['collections'] & string>(name: K): Collection<S['collections'][K]>;
|
|
28
|
+
close(): Promise<void>;
|
|
29
|
+
}
|
|
30
|
+
export interface ConnectOptions<S extends Schema> {
|
|
31
|
+
url: string;
|
|
32
|
+
schema: S;
|
|
33
|
+
getAccessToken(signal: AbortSignal): string | Promise<string>;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
requestTimeoutMs?: number;
|
|
36
|
+
}
|
|
37
|
+
export declare function connect<S extends Schema>(options: ConnectOptions<S>): Promise<Database<S>>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type SyncErrorCode = 'VALIDATION' | 'AUTHENTICATION' | 'DENIED' | 'MISSING_COLLECTION' | 'SCHEMA_MISMATCH' | 'QUERY_LIMIT' | 'CONFLICT' | 'UNAVAILABLE' | 'UNCERTAIN' | 'STORAGE' | 'CLOSED';
|
|
2
|
+
declare const errorBrand: unique symbol;
|
|
3
|
+
export declare class SyncError extends Error {
|
|
4
|
+
readonly code: SyncErrorCode;
|
|
5
|
+
readonly requestId?: string | undefined;
|
|
6
|
+
readonly name = "SyncError";
|
|
7
|
+
private readonly [errorBrand];
|
|
8
|
+
static [Symbol.hasInstance](value: unknown): boolean;
|
|
9
|
+
constructor(code: SyncErrorCode, message: string, requestId?: string | undefined);
|
|
10
|
+
}
|
|
11
|
+
export declare function publicError(error: unknown): SyncError;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JsonValue is the JSON data model accepted by the sync library.
|
|
3
|
+
* Values are null, booleans, finite numbers, strings, arrays, and plain
|
|
4
|
+
* objects (Object.prototype or null prototype). Null is distinct from an
|
|
5
|
+
* absent record; the service handles that distinction separately.
|
|
6
|
+
*/
|
|
7
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | {
|
|
8
|
+
[key: string]: JsonValue;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* CanonicalJSON returns the deterministic compact JSON form of value.
|
|
12
|
+
* Object keys sort lexically by code unit. Accepts only JsonValue shapes:
|
|
13
|
+
* rejects undefined (including properties and array holes), bigint, symbols,
|
|
14
|
+
* functions, non-finite numbers, cycles, class instances, accessor
|
|
15
|
+
* properties, enumerable symbol keys, and extra enumerable array properties.
|
|
16
|
+
* Repeated references without a cycle are allowed. Never calls toJSON or
|
|
17
|
+
* getters. Throws TypeError with a field path and reason, never the value.
|
|
18
|
+
*/
|
|
19
|
+
export declare function canonicalJSON(value: unknown): string;
|
|
20
|
+
/**
|
|
21
|
+
* EncodeJSON returns canonicalJSON(value) as strict UTF-8 bytes.
|
|
22
|
+
* See canonicalJSON for the value contract.
|
|
23
|
+
*/
|
|
24
|
+
export declare function encodeJSON(value: unknown): Uint8Array;
|
|
25
|
+
/**
|
|
26
|
+
* DecodeJSON parses strict UTF-8 data as JSON and validates it as JsonValue.
|
|
27
|
+
* Throws TypeError when the data is not valid UTF-8, not valid JSON, or not
|
|
28
|
+
* a JsonValue (for example a non-finite number from a large exponent).
|
|
29
|
+
*/
|
|
30
|
+
export declare function decodeJSON(data: Uint8Array): JsonValue;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { JsonValue } from './json.js';
|
|
2
|
+
export type Operation = {
|
|
3
|
+
kind: 'get';
|
|
4
|
+
collection: string;
|
|
5
|
+
key: string;
|
|
6
|
+
} | {
|
|
7
|
+
kind: 'scan';
|
|
8
|
+
collection: string;
|
|
9
|
+
prefix: string;
|
|
10
|
+
} | {
|
|
11
|
+
kind: 'put';
|
|
12
|
+
collection: string;
|
|
13
|
+
key: string;
|
|
14
|
+
value: JsonValue;
|
|
15
|
+
} | {
|
|
16
|
+
kind: 'delete';
|
|
17
|
+
collection: string;
|
|
18
|
+
key: string;
|
|
19
|
+
} | {
|
|
20
|
+
kind: 'mutate';
|
|
21
|
+
name: string;
|
|
22
|
+
input: JsonValue;
|
|
23
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from '@standard-schema/spec';
|
|
2
|
+
import { type JsonValue } from './json.js';
|
|
3
|
+
export type Validator = StandardSchemaV1;
|
|
4
|
+
export type Input<S extends Validator> = StandardSchemaV1.InferInput<S>;
|
|
5
|
+
export type Output<S extends Validator> = StandardSchemaV1.InferOutput<S>;
|
|
6
|
+
export interface MutationDefinition {
|
|
7
|
+
readonly input: Validator;
|
|
8
|
+
readonly output: Validator;
|
|
9
|
+
}
|
|
10
|
+
export interface Schema {
|
|
11
|
+
readonly id: string;
|
|
12
|
+
readonly version: number;
|
|
13
|
+
readonly collections: Readonly<Record<string, Validator>>;
|
|
14
|
+
readonly mutations: Readonly<Record<string, MutationDefinition>>;
|
|
15
|
+
}
|
|
16
|
+
export declare function defineSchema<const S extends Schema>(schema: S): S;
|
|
17
|
+
export declare function validate<V extends Validator>(validator: V, value: unknown, field: string): Promise<Output<V> & JsonValue>;
|
|
18
|
+
export interface CallOptions {
|
|
19
|
+
readonly signal?: AbortSignal;
|
|
20
|
+
readonly requestId?: string;
|
|
21
|
+
}
|
|
22
|
+
export interface RecordEntry<T> {
|
|
23
|
+
readonly key: string;
|
|
24
|
+
readonly value: T;
|
|
25
|
+
}
|
|
26
|
+
export interface Query {
|
|
27
|
+
readonly prefix?: string;
|
|
28
|
+
}
|
|
29
|
+
export interface TransactionCollection<V extends Validator> {
|
|
30
|
+
get(key: string): Promise<Output<V> | undefined>;
|
|
31
|
+
scan(query?: Query): Promise<readonly RecordEntry<Output<V>>[]>;
|
|
32
|
+
put(key: string, value: Input<V>): Promise<void>;
|
|
33
|
+
delete(key: string): Promise<void>;
|
|
34
|
+
}
|
|
35
|
+
export interface Transaction<S extends Schema> {
|
|
36
|
+
collection<K extends keyof S['collections'] & string>(name: K): TransactionCollection<S['collections'][K]>;
|
|
37
|
+
}
|
|
38
|
+
export interface Principal {
|
|
39
|
+
readonly subject: string;
|
|
40
|
+
readonly scope: string;
|
|
41
|
+
readonly expiresAt?: number;
|
|
42
|
+
readonly signal?: AbortSignal;
|
|
43
|
+
}
|
|
44
|
+
export interface MutationContext<S extends Schema, P extends Principal> extends Transaction<S> {
|
|
45
|
+
readonly principal: P;
|
|
46
|
+
readonly scope: string;
|
|
47
|
+
readonly signal: AbortSignal;
|
|
48
|
+
}
|
|
49
|
+
export type MutationHandlers<S extends Schema, P extends Principal> = {
|
|
50
|
+
[K in keyof S['mutations']]: (context: MutationContext<S, P>, input: Output<S['mutations'][K]['input']>) => Promise<Input<S['mutations'][K]['output']>>;
|
|
51
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { Database, SubscriptionState } from '../../sdk/sync/client.js';
|
|
3
|
+
import type { Output, Query, Schema } from '../../sdk/sync/schema.js';
|
|
4
|
+
export declare function createSyncContext<S extends Schema>(_schema: S): {
|
|
5
|
+
SyncProvider: ({ db, children, }: {
|
|
6
|
+
db: Database<S>;
|
|
7
|
+
children: ReactNode;
|
|
8
|
+
}) => import("react").JSX.Element;
|
|
9
|
+
useDatabase: () => Database<S>;
|
|
10
|
+
useCollection: <K extends keyof S['collections'] & string>(name: K, query?: Query) => SubscriptionState<Output<S['collections'][K]>>;
|
|
11
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "spacewave-task-board",
|
|
3
|
+
"private": true,
|
|
4
|
+
"type": "module",
|
|
5
|
+
"engines": {
|
|
6
|
+
"node": ">=24.15.0 <25"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"start": "node server.ts",
|
|
10
|
+
"typecheck": "tsc --noEmit"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"spacewave": "0.57.6",
|
|
14
|
+
"react": "^19.2.8",
|
|
15
|
+
"react-dom": "^19.2.8",
|
|
16
|
+
"zod": "^4.3.6"
|
|
17
|
+
},
|
|
18
|
+
"devDependencies": {
|
|
19
|
+
"@types/node": "^24.0.0",
|
|
20
|
+
"@types/react": "^19.2.18",
|
|
21
|
+
"@types/react-dom": "^19.2.4",
|
|
22
|
+
"esbuild": "^0.27.0",
|
|
23
|
+
"typescript": "^7.0.2"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
|
6
|
+
<title>Task board · Spacewave React</title>
|
|
7
|
+
<link rel="stylesheet" href="/style.css" />
|
|
8
|
+
</head>
|
|
9
|
+
|
|
10
|
+
<body>
|
|
11
|
+
<main id="app">
|
|
12
|
+
<p role="status">Connecting…</p>
|
|
13
|
+
</main>
|
|
14
|
+
|
|
15
|
+
<script type="module" src="/react.js"></script>
|
|
16
|
+
</body>
|
|
17
|
+
</html>
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useState,
|
|
4
|
+
useSyncExternalStore,
|
|
5
|
+
type FormEvent,
|
|
6
|
+
} from 'react'
|
|
7
|
+
import { createRoot } from 'react-dom/client'
|
|
8
|
+
import { connect, SyncError, type ConnectionState } from 'spacewave'
|
|
9
|
+
import { createSyncContext } from 'spacewave/react'
|
|
10
|
+
|
|
11
|
+
import { schema } from './schema.ts'
|
|
12
|
+
|
|
13
|
+
const { SyncProvider, useDatabase, useCollection } = createSyncContext(schema)
|
|
14
|
+
|
|
15
|
+
/** UncertainWrite retains the original input and request ID for an explicit retry. */
|
|
16
|
+
interface UncertainWrite {
|
|
17
|
+
requestId: string
|
|
18
|
+
message: string
|
|
19
|
+
retry: () => void
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const connectionLabels: Record<ConnectionState['status'], string> = {
|
|
23
|
+
ready: 'Connected',
|
|
24
|
+
reconnecting: 'Reconnecting…',
|
|
25
|
+
closed: 'Disconnected',
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** TaskBoard renders accepted tasks and keeps pending form input separate. */
|
|
29
|
+
function TaskBoard() {
|
|
30
|
+
// Read task and connection state from the database's existing subscriptions.
|
|
31
|
+
const db = useDatabase()
|
|
32
|
+
const todos = useCollection('todos')
|
|
33
|
+
const connection = useSyncExternalStore(
|
|
34
|
+
db.connection.subscribe,
|
|
35
|
+
() => db.connection.current,
|
|
36
|
+
() => db.connection.current,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
// Keep editable input and unresolved writes outside the accepted task snapshot.
|
|
40
|
+
const [title, setTitle] = useState('')
|
|
41
|
+
const [pending, setPending] = useState(false)
|
|
42
|
+
const [uncertain, setUncertain] = useState<UncertainWrite | null>(null)
|
|
43
|
+
const [writeError, setWriteError] = useState<string | null>(null)
|
|
44
|
+
|
|
45
|
+
/** runWrite exposes an uncertain write's original operation for a later retry. */
|
|
46
|
+
const runWrite = useCallback(
|
|
47
|
+
async (run: () => Promise<unknown>, requestId: string): Promise<void> => {
|
|
48
|
+
// Suspend new edits and clear the previous write's feedback.
|
|
49
|
+
setPending(true)
|
|
50
|
+
setUncertain(null)
|
|
51
|
+
setWriteError(null)
|
|
52
|
+
|
|
53
|
+
// Keep failed writes recoverable without inserting unaccepted task rows.
|
|
54
|
+
try {
|
|
55
|
+
await run()
|
|
56
|
+
} catch (error) {
|
|
57
|
+
if (error instanceof SyncError && error.code === 'UNCERTAIN') {
|
|
58
|
+
setUncertain({
|
|
59
|
+
requestId,
|
|
60
|
+
message: error.message,
|
|
61
|
+
retry: () => {
|
|
62
|
+
void runWrite(run, requestId)
|
|
63
|
+
},
|
|
64
|
+
})
|
|
65
|
+
} else {
|
|
66
|
+
setWriteError(error instanceof Error ? error.message : String(error))
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
setPending(false)
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
[],
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
/** addTask preserves the title and generated IDs until the write is accepted. */
|
|
76
|
+
const addTask = (event: FormEvent<HTMLFormElement>) => {
|
|
77
|
+
// Accept a nonempty title only while connected and ready for another write.
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
const trimmed = title.trim()
|
|
80
|
+
if (!trimmed || pending || connection.status !== 'ready') return
|
|
81
|
+
|
|
82
|
+
// Capture the write once so Retry submits the same input with the same ID.
|
|
83
|
+
const id = crypto.randomUUID()
|
|
84
|
+
const requestId = crypto.randomUUID()
|
|
85
|
+
void runWrite(async () => {
|
|
86
|
+
await db
|
|
87
|
+
.collection('todos')
|
|
88
|
+
.put(id, { title: trimmed, done: false }, { requestId })
|
|
89
|
+
setTitle('')
|
|
90
|
+
}, requestId)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** completeTask invokes the server mutation with a recoverable request ID. */
|
|
94
|
+
const completeTask = (id: string) => {
|
|
95
|
+
const requestId = crypto.randomUUID()
|
|
96
|
+
void runWrite(
|
|
97
|
+
() => db.mutate('completeTodo', { id }, { requestId }),
|
|
98
|
+
requestId,
|
|
99
|
+
)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Describe the snapshot's freshness without treating stale data as current.
|
|
103
|
+
const todosStatus =
|
|
104
|
+
todos.status === 'loading'
|
|
105
|
+
? 'Loading tasks…'
|
|
106
|
+
: todos.status === 'current'
|
|
107
|
+
? `Live · ${todos.data.length} ${
|
|
108
|
+
todos.data.length === 1 ? 'task' : 'tasks'
|
|
109
|
+
}`
|
|
110
|
+
: 'Showing the last saved view while reconnecting…'
|
|
111
|
+
|
|
112
|
+
// Render connection feedback, write controls, and the accepted task list.
|
|
113
|
+
return (
|
|
114
|
+
<>
|
|
115
|
+
<h1>Task board</h1>
|
|
116
|
+
<p>
|
|
117
|
+
Two tabs share one server: the <a href="/">Vanilla</a> example and this{' '}
|
|
118
|
+
<a href="/react">React</a> example.
|
|
119
|
+
</p>
|
|
120
|
+
|
|
121
|
+
<p className="status">
|
|
122
|
+
{`Connection: ${connectionLabels[connection.status]}`}
|
|
123
|
+
{connection.error ? `: ${connection.error.message}` : ''}
|
|
124
|
+
</p>
|
|
125
|
+
{todos.status === 'error' && todos.error ? (
|
|
126
|
+
<p className="notice" role="alert">
|
|
127
|
+
{todos.error.message}
|
|
128
|
+
</p>
|
|
129
|
+
) : (
|
|
130
|
+
<p className="status">{todosStatus}</p>
|
|
131
|
+
)}
|
|
132
|
+
|
|
133
|
+
<form onSubmit={addTask}>
|
|
134
|
+
<label htmlFor="new-task">
|
|
135
|
+
Add a task
|
|
136
|
+
<input
|
|
137
|
+
id="new-task"
|
|
138
|
+
value={title}
|
|
139
|
+
onChange={(event) => setTitle(event.target.value)}
|
|
140
|
+
disabled={pending}
|
|
141
|
+
required
|
|
142
|
+
maxLength={120}
|
|
143
|
+
/>
|
|
144
|
+
</label>
|
|
145
|
+
<button
|
|
146
|
+
className="button"
|
|
147
|
+
type="submit"
|
|
148
|
+
disabled={pending || connection.status !== 'ready' || !title.trim()}
|
|
149
|
+
>
|
|
150
|
+
Add task
|
|
151
|
+
</button>
|
|
152
|
+
</form>
|
|
153
|
+
|
|
154
|
+
{writeError ? <p className="notice">{writeError}</p> : null}
|
|
155
|
+
{uncertain ? (
|
|
156
|
+
<p className="notice">
|
|
157
|
+
{uncertain.message} The write may not have been accepted (request{' '}
|
|
158
|
+
{uncertain.requestId}).{' '}
|
|
159
|
+
<button
|
|
160
|
+
className="button"
|
|
161
|
+
type="button"
|
|
162
|
+
disabled={pending || connection.status !== 'ready'}
|
|
163
|
+
onClick={uncertain.retry}
|
|
164
|
+
>
|
|
165
|
+
Retry
|
|
166
|
+
</button>
|
|
167
|
+
</p>
|
|
168
|
+
) : null}
|
|
169
|
+
|
|
170
|
+
<ul className="tasks">
|
|
171
|
+
{todos.data.map((entry) => (
|
|
172
|
+
<li className="task" key={entry.key} data-done={entry.value.done}>
|
|
173
|
+
<span>{entry.value.title}</span>{' '}
|
|
174
|
+
<button
|
|
175
|
+
className="button"
|
|
176
|
+
type="button"
|
|
177
|
+
disabled={
|
|
178
|
+
pending || connection.status !== 'ready' || entry.value.done
|
|
179
|
+
}
|
|
180
|
+
onClick={() => completeTask(entry.key)}
|
|
181
|
+
>
|
|
182
|
+
{entry.value.done ? 'Completed' : 'Complete'}
|
|
183
|
+
</button>
|
|
184
|
+
</li>
|
|
185
|
+
))}
|
|
186
|
+
</ul>
|
|
187
|
+
</>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** start connects the board and releases its React tree and database on pagehide. */
|
|
192
|
+
async function start(): Promise<void> {
|
|
193
|
+
// Require the page's mount point before opening a connection.
|
|
194
|
+
const container = document.getElementById('app')
|
|
195
|
+
if (!container) throw new Error('Missing #app mount point')
|
|
196
|
+
|
|
197
|
+
// Connect to the sync endpoint served alongside this page.
|
|
198
|
+
const url = `${location.protocol === 'https:' ? 'wss' : 'ws'}://${
|
|
199
|
+
location.host
|
|
200
|
+
}/sync`
|
|
201
|
+
const db = await connect({
|
|
202
|
+
url,
|
|
203
|
+
schema,
|
|
204
|
+
getAccessToken: () => 'local-demo',
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Supply the admitted database to every task-board component.
|
|
208
|
+
const root = createRoot(container)
|
|
209
|
+
root.render(
|
|
210
|
+
<SyncProvider db={db}>
|
|
211
|
+
<TaskBoard />
|
|
212
|
+
</SyncProvider>,
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
// Unmount subscriptions before closing the database they use.
|
|
216
|
+
window.addEventListener(
|
|
217
|
+
'pagehide',
|
|
218
|
+
() => {
|
|
219
|
+
root.unmount()
|
|
220
|
+
void db.close()
|
|
221
|
+
},
|
|
222
|
+
{ once: true },
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Replace the loading view with a readable failure if startup cannot finish.
|
|
227
|
+
start().catch((error: unknown) => {
|
|
228
|
+
const container = document.getElementById('app') ?? document.body
|
|
229
|
+
const notice = document.createElement('p')
|
|
230
|
+
notice.className = 'notice'
|
|
231
|
+
notice.textContent = `Task board failed to start: ${
|
|
232
|
+
error instanceof Error ? error.message : String(error)
|
|
233
|
+
}`
|
|
234
|
+
container.replaceChildren(notice)
|
|
235
|
+
})
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { defineSchema } from 'spacewave'
|
|
2
|
+
import { z } from 'zod'
|
|
3
|
+
|
|
4
|
+
/** todo validates stored tasks and the result of completing a task. */
|
|
5
|
+
const todo = z.object({
|
|
6
|
+
title: z.string().min(1).max(120),
|
|
7
|
+
done: z.boolean(),
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
/** schema is the shared contract for the server, vanilla client, and React client. */
|
|
11
|
+
export const schema = defineSchema({
|
|
12
|
+
id: 'task-board',
|
|
13
|
+
version: 1,
|
|
14
|
+
collections: { todos: todo },
|
|
15
|
+
mutations: {
|
|
16
|
+
completeTodo: {
|
|
17
|
+
input: z.object({ id: z.string().min(1) }),
|
|
18
|
+
output: todo,
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import { createServer as createHTTPServer } from 'node:http'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { build } from 'esbuild'
|
|
6
|
+
import { SyncError } from 'spacewave'
|
|
7
|
+
import { createServer } from 'spacewave/server'
|
|
8
|
+
|
|
9
|
+
import { schema } from './schema.ts'
|
|
10
|
+
|
|
11
|
+
// Build both browser entry points before accepting page requests.
|
|
12
|
+
const directory = dirname(fileURLToPath(import.meta.url))
|
|
13
|
+
await build({
|
|
14
|
+
absWorkingDir: directory,
|
|
15
|
+
entryPoints: ['vanilla.ts', 'react.tsx'],
|
|
16
|
+
outdir: 'public',
|
|
17
|
+
bundle: true,
|
|
18
|
+
format: 'esm',
|
|
19
|
+
platform: 'browser',
|
|
20
|
+
target: 'es2024',
|
|
21
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Open the persistent task dataset and admit only the local demo identity.
|
|
25
|
+
const server = await createServer({
|
|
26
|
+
directory: process.env.DATA_DIRECTORY ?? join(directory, '.data'),
|
|
27
|
+
schema,
|
|
28
|
+
|
|
29
|
+
// Replace this verifier with the application's real authentication provider.
|
|
30
|
+
authenticate: async (token) => {
|
|
31
|
+
if (token !== 'local-demo') {
|
|
32
|
+
throw new SyncError('AUTHENTICATION', 'Sign in again')
|
|
33
|
+
}
|
|
34
|
+
return { subject: 'demo-user', scope: 'demo' }
|
|
35
|
+
},
|
|
36
|
+
authorize: ({ scope }) => scope === 'demo',
|
|
37
|
+
|
|
38
|
+
// Mutations read and write through one server transaction.
|
|
39
|
+
mutations: {
|
|
40
|
+
completeTodo: async (context, { id }) => {
|
|
41
|
+
// Read the task before changing its completion state.
|
|
42
|
+
const todos = context.collection('todos')
|
|
43
|
+
const todo = await todos.get(id)
|
|
44
|
+
if (!todo) {
|
|
45
|
+
throw new SyncError('VALIDATION', 'This task no longer exists')
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Update the task within this transaction and return its value.
|
|
49
|
+
const updated = { ...todo, done: true }
|
|
50
|
+
await todos.put(id, updated)
|
|
51
|
+
return updated
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
// Seed the board through the same authorized collection API used by clients.
|
|
57
|
+
const tasks = server
|
|
58
|
+
.as({ subject: 'demo-user', scope: 'demo' })
|
|
59
|
+
.collection('todos')
|
|
60
|
+
if (!(await tasks.get('welcome'))) {
|
|
61
|
+
await tasks.put('welcome', {
|
|
62
|
+
title: 'Open this board in two tabs',
|
|
63
|
+
done: false,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Serve only the example's page, bundle, and stylesheet routes.
|
|
68
|
+
const files = new Map([
|
|
69
|
+
['/', ['vanilla.html', 'text/html']],
|
|
70
|
+
['/react', ['react.html', 'text/html']],
|
|
71
|
+
['/vanilla.js', ['public/vanilla.js', 'text/javascript']],
|
|
72
|
+
['/react.js', ['public/react.js', 'text/javascript']],
|
|
73
|
+
['/style.css', ['style.css', 'text/css']],
|
|
74
|
+
])
|
|
75
|
+
const http = createHTTPServer((request, response) => {
|
|
76
|
+
// Resolve the request through the fixed route map.
|
|
77
|
+
const path = new URL(request.url ?? '/', 'http://localhost').pathname
|
|
78
|
+
const file = files.get(path)
|
|
79
|
+
if (!file) {
|
|
80
|
+
response.writeHead(404)
|
|
81
|
+
response.end()
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Send the matching asset or report a read failure.
|
|
86
|
+
const [filename, contentType] = file
|
|
87
|
+
void readFile(join(directory, filename)).then(
|
|
88
|
+
(data) => {
|
|
89
|
+
response.setHeader('Content-Type', contentType)
|
|
90
|
+
response.end(data)
|
|
91
|
+
},
|
|
92
|
+
() => {
|
|
93
|
+
response.writeHead(500)
|
|
94
|
+
response.end('The page could not load')
|
|
95
|
+
},
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// Share the HTTP listener between pages and the sync endpoint.
|
|
100
|
+
server.attach(http)
|
|
101
|
+
const port = Number(process.env.PORT ?? 8787)
|
|
102
|
+
await new Promise<void>((resolve, reject) => {
|
|
103
|
+
http.once('error', reject)
|
|
104
|
+
http.listen(port, '127.0.0.1', resolve)
|
|
105
|
+
})
|
|
106
|
+
const address = http.address()
|
|
107
|
+
if (address && typeof address !== 'string') {
|
|
108
|
+
console.log(`Task board: http://127.0.0.1:${address.port}`)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Repeated shutdown signals share one close operation.
|
|
112
|
+
let closing: Promise<void> | undefined
|
|
113
|
+
|
|
114
|
+
/** close releases sync connections and storage, then the HTTP server we supplied. */
|
|
115
|
+
function close(): Promise<void> {
|
|
116
|
+
closing ??= (async () => {
|
|
117
|
+
await server.close()
|
|
118
|
+
await new Promise<void>((resolve, reject) =>
|
|
119
|
+
http.close((error) => (error ? reject(error) : resolve())),
|
|
120
|
+
)
|
|
121
|
+
})()
|
|
122
|
+
return closing
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Let both normal interruption and service termination finish cleanup.
|
|
126
|
+
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
|
|
127
|
+
process.once(signal, () => {
|
|
128
|
+
void close().catch((error: unknown) => {
|
|
129
|
+
console.error(error)
|
|
130
|
+
process.exitCode = 1
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
}
|