spacewave 0.0.0 → 0.57.7

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.
Files changed (36) hide show
  1. package/AGENTS.md +82 -0
  2. package/API.md +364 -0
  3. package/CHANGELOG.md +11 -0
  4. package/LICENSE +191 -0
  5. package/README.md +349 -0
  6. package/dist/THIRD_PARTY_NOTICES.txt +4497 -0
  7. package/dist/build.json +6 -0
  8. package/dist/chunks/errors-BHnASqFk.mjs +2 -0
  9. package/dist/chunks/node.pb-ChF26ESv.mjs +2 -0
  10. package/dist/chunks/rolldown-runtime-Csi-cuKc.mjs +1 -0
  11. package/dist/engine-worker.mjs +112 -0
  12. package/dist/index.mjs +1 -0
  13. package/dist/react.mjs +1 -0
  14. package/dist/server.mjs +10 -0
  15. package/dist/types/core/sync/config.d.ts +55 -0
  16. package/dist/types/packages/spacewave/index.d.ts +7 -0
  17. package/dist/types/packages/spacewave/react.d.ts +1 -0
  18. package/dist/types/packages/spacewave/server.d.ts +4 -0
  19. package/dist/types/sdk/sync/access.d.ts +15 -0
  20. package/dist/types/sdk/sync/client.d.ts +37 -0
  21. package/dist/types/sdk/sync/errors.d.ts +12 -0
  22. package/dist/types/sdk/sync/json.d.ts +30 -0
  23. package/dist/types/sdk/sync/operation.d.ts +23 -0
  24. package/dist/types/sdk/sync/schema.d.ts +51 -0
  25. package/dist/types/web/sync/index.d.ts +4 -0
  26. package/dist/types/web/sync/react.d.ts +11 -0
  27. package/examples/task-board/package.json +25 -0
  28. package/examples/task-board/react.html +17 -0
  29. package/examples/task-board/react.tsx +235 -0
  30. package/examples/task-board/schema.ts +21 -0
  31. package/examples/task-board/server.ts +133 -0
  32. package/examples/task-board/style.css +154 -0
  33. package/examples/task-board/tsconfig.json +15 -0
  34. package/examples/task-board/vanilla.html +17 -0
  35. package/examples/task-board/vanilla.ts +190 -0
  36. 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,4 @@
1
+ export { createSyncContext } from './react.js';
2
+ export type { Database, SubscriptionState } from '../../sdk/sync/client.js';
3
+ export type { Output, Query, Schema } from '../../sdk/sync/schema.js';
4
+ export { SyncError } from '../../sdk/sync/errors.js';
@@ -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.7",
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
+ }