@absolutejs/sync 0.0.1
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/README.md +102 -0
- package/dist/client/index.d.ts +31 -0
- package/dist/client/index.js +41 -0
- package/dist/client/index.js.map +10 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +176 -0
- package/dist/index.js.map +12 -0
- package/dist/plugin.d.ts +69 -0
- package/dist/reactiveHub.d.ts +34 -0
- package/dist/writeBehindCache.d.ts +58 -0
- package/package.json +56 -0
package/README.md
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# @absolutejs/sync
|
|
2
|
+
|
|
3
|
+
Two small, composable primitives for live data in [Elysia](https://elysiajs.com)
|
|
4
|
+
and the AbsoluteJS ecosystem:
|
|
5
|
+
|
|
6
|
+
- **`createReactiveHub` + `sync` plugin** — push-on-change over SSE so clients stop
|
|
7
|
+
polling. A widget subscribes to the topics its data depends on; a mutation
|
|
8
|
+
publishes those topics; subscribers refetch (or read the pushed payload) the
|
|
9
|
+
instant data changes.
|
|
10
|
+
- **`createWriteBehindCache`** — an in-memory hot cache with write-behind
|
|
11
|
+
persistence, so a latency-sensitive hot path doesn't pay a round-trip to a remote
|
|
12
|
+
store on every read/write.
|
|
13
|
+
|
|
14
|
+
It is **not a sync engine.** Convex, ElectricSQL, and Zero are whole backends —
|
|
15
|
+
read-set tracking, OCC/MVCC, a transaction log, client SQLite replicas. This package
|
|
16
|
+
does **not** rebuild any of that. It's a thin reactive layer over the store and
|
|
17
|
+
transport you already have: pair it with **Drizzle _or_ Prisma** (or any store) and
|
|
18
|
+
your existing SSE/WebSocket. Dependencies are explicit (you name topics), not
|
|
19
|
+
auto-tracked from query read sets. If you want a full local-first sync engine, reach
|
|
20
|
+
for one of the above; if you just want to delete your polling loop and keep a remote
|
|
21
|
+
DB off your hot path, reach for this.
|
|
22
|
+
|
|
23
|
+
> Status: early (`0.0.1`). In-memory hub, write-behind cache, Elysia SSE plugin, and
|
|
24
|
+
> a browser subscriber. Durable/transport adapters land in a companion
|
|
25
|
+
> `-adapters` package as the API settles.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
bun add @absolutejs/sync
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`elysia` is an optional peer (only needed for the `sync` plugin).
|
|
34
|
+
|
|
35
|
+
## Reactive push — kill the polling loop
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
// server
|
|
39
|
+
import { Elysia } from 'elysia';
|
|
40
|
+
import { createReactiveHub, sync } from '@absolutejs/sync';
|
|
41
|
+
|
|
42
|
+
const hub = createReactiveHub();
|
|
43
|
+
|
|
44
|
+
new Elysia()
|
|
45
|
+
.use(sync({ hub })) // serves SSE at GET /sync?topics=a,b,c
|
|
46
|
+
.post('/orders', async ({ body }) => {
|
|
47
|
+
const order = await db.orders.insert(body); // your Drizzle/Prisma write
|
|
48
|
+
hub.publish('orders'); // notify everyone watching "orders"
|
|
49
|
+
hub.publish(`orders:${order.id}`); // …and this one specifically
|
|
50
|
+
return order;
|
|
51
|
+
})
|
|
52
|
+
.listen(3000);
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```ts
|
|
56
|
+
// browser
|
|
57
|
+
import { createSyncSubscriber } from '@absolutejs/sync/client';
|
|
58
|
+
|
|
59
|
+
const sub = createSyncSubscriber({
|
|
60
|
+
topics: ['orders', 'orders:*'], // trailing * matches by prefix
|
|
61
|
+
onEvent: (event) => {
|
|
62
|
+
// data changed — refetch instead of polling on a timer
|
|
63
|
+
if (event.topic.startsWith('orders')) refetchOrders();
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
// sub.close() when the view unmounts
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Write-behind cache — keep a remote store off your hot path
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { createWriteBehindCache } from '@absolutejs/sync';
|
|
73
|
+
|
|
74
|
+
const sessions = createWriteBehindCache({
|
|
75
|
+
load: (id) => db.sessions.get(id), // read-through on a miss
|
|
76
|
+
persist: (id, value) => db.sessions.set(id, value), // coalesced background write
|
|
77
|
+
remove: (id) => db.sessions.delete(id),
|
|
78
|
+
debounceMs: 250,
|
|
79
|
+
evict: (value) => value.status === 'closed' // drop terminal entries
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
sessions.set('s1', next); // synchronous; persists ~250ms later
|
|
83
|
+
const current = await sessions.get('s1'); // from memory
|
|
84
|
+
await sessions.flush(); // on shutdown
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
This is what `@absolutejs/voice` uses to keep its per-audio-frame session state in
|
|
88
|
+
memory while the Drizzle/Postgres store stays the durable source of truth — without
|
|
89
|
+
it, ~3 store round-trips every 20ms ran the voice pipeline far slower than real time.
|
|
90
|
+
|
|
91
|
+
## API
|
|
92
|
+
|
|
93
|
+
| Export | What it is |
|
|
94
|
+
| --- | --- |
|
|
95
|
+
| `createReactiveHub()` | In-memory topic pub/sub (`publish`, `subscribe`, `subscriberCount`). |
|
|
96
|
+
| `sync({ hub, path?, resolveTopics?, heartbeatMs? })` | Elysia plugin: SSE stream of hub events. |
|
|
97
|
+
| `createSyncSubscriber({ topics, onEvent, url? })` | Browser SSE client (from `@absolutejs/sync/client`). |
|
|
98
|
+
| `createWriteBehindCache({ load, persist, remove?, debounceMs?, evict?, onPersistError? })` | In-memory cache + write-behind persistence. |
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ReactiveEvent } from '../reactiveHub';
|
|
2
|
+
export type { ReactiveEvent } from '../reactiveHub';
|
|
3
|
+
export type SyncSubscriberOptions = {
|
|
4
|
+
/** Topics to subscribe to. A trailing `*` matches by prefix server-side. */
|
|
5
|
+
topics: string[];
|
|
6
|
+
/** Called for every reactive event pushed from the server. */
|
|
7
|
+
onEvent: (event: ReactiveEvent) => void;
|
|
8
|
+
/** SSE endpoint mounted by the {@link sync} plugin. Defaults to `/sync`. */
|
|
9
|
+
url?: string;
|
|
10
|
+
onOpen?: () => void;
|
|
11
|
+
onError?: (event: Event) => void;
|
|
12
|
+
/** Send cookies with the SSE request (cross-origin auth). */
|
|
13
|
+
withCredentials?: boolean;
|
|
14
|
+
/**
|
|
15
|
+
* EventSource implementation to use. Defaults to the global one; pass a polyfill
|
|
16
|
+
* for non-browser runtimes.
|
|
17
|
+
*/
|
|
18
|
+
eventSourceImpl?: typeof EventSource;
|
|
19
|
+
};
|
|
20
|
+
export type SyncSubscriber = {
|
|
21
|
+
close: () => void;
|
|
22
|
+
/** The underlying EventSource, for advanced listeners. */
|
|
23
|
+
source: EventSource;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* Subscribe a browser to the server's {@link ReactiveHub} over SSE. `onEvent` fires
|
|
27
|
+
* whenever a subscribed topic is published — the cue to refetch (or read the pushed
|
|
28
|
+
* payload) instead of polling. EventSource reconnects automatically on transient
|
|
29
|
+
* network drops.
|
|
30
|
+
*/
|
|
31
|
+
export declare const createSyncSubscriber: ({ topics, onEvent, url, onOpen, onError, withCredentials, eventSourceImpl }: SyncSubscriberOptions) => SyncSubscriber;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/client/index.ts
|
|
2
|
+
var createSyncSubscriber = ({
|
|
3
|
+
topics,
|
|
4
|
+
onEvent,
|
|
5
|
+
url = "/sync",
|
|
6
|
+
onOpen,
|
|
7
|
+
onError,
|
|
8
|
+
withCredentials,
|
|
9
|
+
eventSourceImpl
|
|
10
|
+
}) => {
|
|
11
|
+
const Impl = eventSourceImpl ?? globalThis.EventSource;
|
|
12
|
+
if (!Impl) {
|
|
13
|
+
throw new Error("createSyncSubscriber requires EventSource. Run in a browser or pass eventSourceImpl.");
|
|
14
|
+
}
|
|
15
|
+
const params = new URLSearchParams({ topics: topics.join(",") });
|
|
16
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
17
|
+
const source = new Impl(`${url}${separator}${params.toString()}`, {
|
|
18
|
+
withCredentials: withCredentials ?? false
|
|
19
|
+
});
|
|
20
|
+
source.onmessage = (event) => {
|
|
21
|
+
try {
|
|
22
|
+
onEvent(JSON.parse(event.data));
|
|
23
|
+
} catch {}
|
|
24
|
+
};
|
|
25
|
+
if (onOpen) {
|
|
26
|
+
source.onopen = () => onOpen();
|
|
27
|
+
}
|
|
28
|
+
if (onError) {
|
|
29
|
+
source.onerror = (event) => onError(event);
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
close: () => source.close(),
|
|
33
|
+
source
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
export {
|
|
37
|
+
createSyncSubscriber
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
//# debugId=10D2496AED02ED9364756E2164756E21
|
|
41
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../src/client/index.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"import type { ReactiveEvent } from '../reactiveHub';\n\nexport type { ReactiveEvent } from '../reactiveHub';\n\nexport type SyncSubscriberOptions = {\n\t/** Topics to subscribe to. A trailing `*` matches by prefix server-side. */\n\ttopics: string[];\n\t/** Called for every reactive event pushed from the server. */\n\tonEvent: (event: ReactiveEvent) => void;\n\t/** SSE endpoint mounted by the {@link sync} plugin. Defaults to `/sync`. */\n\turl?: string;\n\tonOpen?: () => void;\n\tonError?: (event: Event) => void;\n\t/** Send cookies with the SSE request (cross-origin auth). */\n\twithCredentials?: boolean;\n\t/**\n\t * EventSource implementation to use. Defaults to the global one; pass a polyfill\n\t * for non-browser runtimes.\n\t */\n\teventSourceImpl?: typeof EventSource;\n};\n\nexport type SyncSubscriber = {\n\tclose: () => void;\n\t/** The underlying EventSource, for advanced listeners. */\n\tsource: EventSource;\n};\n\n/**\n * Subscribe a browser to the server's {@link ReactiveHub} over SSE. `onEvent` fires\n * whenever a subscribed topic is published — the cue to refetch (or read the pushed\n * payload) instead of polling. EventSource reconnects automatically on transient\n * network drops.\n */\nexport const createSyncSubscriber = ({\n\ttopics,\n\tonEvent,\n\turl = '/sync',\n\tonOpen,\n\tonError,\n\twithCredentials,\n\teventSourceImpl\n}: SyncSubscriberOptions): SyncSubscriber => {\n\tconst Impl = eventSourceImpl ?? globalThis.EventSource;\n\tif (!Impl) {\n\t\tthrow new Error(\n\t\t\t'createSyncSubscriber requires EventSource. Run in a browser or pass eventSourceImpl.'\n\t\t);\n\t}\n\n\tconst params = new URLSearchParams({ topics: topics.join(',') });\n\tconst separator = url.includes('?') ? '&' : '?';\n\tconst source = new Impl(`${url}${separator}${params.toString()}`, {\n\t\twithCredentials: withCredentials ?? false\n\t});\n\n\tsource.onmessage = (event) => {\n\t\ttry {\n\t\t\tonEvent(JSON.parse(event.data) as ReactiveEvent);\n\t\t} catch {\n\t\t\t// ignore heartbeats / non-JSON frames\n\t\t}\n\t};\n\tif (onOpen) {\n\t\tsource.onopen = () => onOpen();\n\t}\n\tif (onError) {\n\t\tsource.onerror = (event) => onError(event);\n\t}\n\n\treturn {\n\t\tclose: () => source.close(),\n\t\tsource\n\t};\n};\n"
|
|
6
|
+
],
|
|
7
|
+
"mappings": ";AAkCO,IAAM,uBAAuB;AAAA,EACnC;AAAA,EACA;AAAA,EACA,MAAM;AAAA,EACN;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,MAC4C;AAAA,EAC5C,MAAM,OAAO,mBAAmB,WAAW;AAAA,EAC3C,IAAI,CAAC,MAAM;AAAA,IACV,MAAM,IAAI,MACT,sFACD;AAAA,EACD;AAAA,EAEA,MAAM,SAAS,IAAI,gBAAgB,EAAE,QAAQ,OAAO,KAAK,GAAG,EAAE,CAAC;AAAA,EAC/D,MAAM,YAAY,IAAI,SAAS,GAAG,IAAI,MAAM;AAAA,EAC5C,MAAM,SAAS,IAAI,KAAK,GAAG,MAAM,YAAY,OAAO,SAAS,KAAK;AAAA,IACjE,iBAAiB,mBAAmB;AAAA,EACrC,CAAC;AAAA,EAED,OAAO,YAAY,CAAC,UAAU;AAAA,IAC7B,IAAI;AAAA,MACH,QAAQ,KAAK,MAAM,MAAM,IAAI,CAAkB;AAAA,MAC9C,MAAM;AAAA;AAAA,EAIT,IAAI,QAAQ;AAAA,IACX,OAAO,SAAS,MAAM,OAAO;AAAA,EAC9B;AAAA,EACA,IAAI,SAAS;AAAA,IACZ,OAAO,UAAU,CAAC,UAAU,QAAQ,KAAK;AAAA,EAC1C;AAAA,EAEA,OAAO;AAAA,IACN,OAAO,MAAM,OAAO,MAAM;AAAA,IAC1B;AAAA,EACD;AAAA;",
|
|
8
|
+
"debugId": "10D2496AED02ED9364756E2164756E21",
|
|
9
|
+
"names": []
|
|
10
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { createWriteBehindCache } from './writeBehindCache';
|
|
2
|
+
export type { WriteBehindCache, WriteBehindCacheOptions } from './writeBehindCache';
|
|
3
|
+
export { createReactiveHub } from './reactiveHub';
|
|
4
|
+
export type { ReactiveEvent, ReactiveHub, ReactiveListener } from './reactiveHub';
|
|
5
|
+
export { sync } from './plugin';
|
|
6
|
+
export type { SyncPluginOptions, SyncRequestContext } from './plugin';
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/writeBehindCache.ts
|
|
3
|
+
var createWriteBehindCache = (options) => {
|
|
4
|
+
const debounceMs = options.debounceMs ?? 250;
|
|
5
|
+
const cache = new Map;
|
|
6
|
+
const timers = new Map;
|
|
7
|
+
const persist = async (key) => {
|
|
8
|
+
timers.delete(key);
|
|
9
|
+
const value = cache.get(key);
|
|
10
|
+
if (value === undefined) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
await options.persist(key, value);
|
|
15
|
+
if (options.evict?.(value, key)) {
|
|
16
|
+
cache.delete(key);
|
|
17
|
+
}
|
|
18
|
+
} catch (error) {
|
|
19
|
+
options.onPersistError?.(error, key);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
const schedulePersist = (key) => {
|
|
23
|
+
if (timers.has(key)) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
timers.set(key, setTimeout(() => {
|
|
27
|
+
persist(key);
|
|
28
|
+
}, debounceMs));
|
|
29
|
+
};
|
|
30
|
+
return {
|
|
31
|
+
get: async (key) => {
|
|
32
|
+
const cached = cache.get(key);
|
|
33
|
+
if (cached !== undefined) {
|
|
34
|
+
return cached;
|
|
35
|
+
}
|
|
36
|
+
const loaded = await options.load(key);
|
|
37
|
+
if (loaded !== undefined) {
|
|
38
|
+
cache.set(key, loaded);
|
|
39
|
+
}
|
|
40
|
+
return loaded;
|
|
41
|
+
},
|
|
42
|
+
peek: (key) => cache.get(key),
|
|
43
|
+
has: (key) => cache.has(key),
|
|
44
|
+
set: (key, value) => {
|
|
45
|
+
cache.set(key, value);
|
|
46
|
+
schedulePersist(key);
|
|
47
|
+
},
|
|
48
|
+
delete: async (key) => {
|
|
49
|
+
const timer = timers.get(key);
|
|
50
|
+
if (timer) {
|
|
51
|
+
clearTimeout(timer);
|
|
52
|
+
timers.delete(key);
|
|
53
|
+
}
|
|
54
|
+
cache.delete(key);
|
|
55
|
+
await options.remove?.(key);
|
|
56
|
+
},
|
|
57
|
+
keys: () => cache.keys(),
|
|
58
|
+
values: () => cache.values(),
|
|
59
|
+
size: () => cache.size,
|
|
60
|
+
flush: async () => {
|
|
61
|
+
for (const timer of timers.values()) {
|
|
62
|
+
clearTimeout(timer);
|
|
63
|
+
}
|
|
64
|
+
timers.clear();
|
|
65
|
+
await Promise.all([...cache.keys()].map((key) => persist(key)));
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
};
|
|
69
|
+
// src/reactiveHub.ts
|
|
70
|
+
var createReactiveHub = () => {
|
|
71
|
+
const subscriptions = new Set;
|
|
72
|
+
const matches = (subscription, topic) => {
|
|
73
|
+
if (subscription.exact.has(topic)) {
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
return subscription.prefixes.some((prefix) => topic.startsWith(prefix));
|
|
77
|
+
};
|
|
78
|
+
return {
|
|
79
|
+
publish: (topic, payload) => {
|
|
80
|
+
const event = { topic, at: Date.now(), payload };
|
|
81
|
+
for (const subscription of subscriptions) {
|
|
82
|
+
if (matches(subscription, topic)) {
|
|
83
|
+
subscription.listener(event);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
},
|
|
87
|
+
subscribe: (topics, listener) => {
|
|
88
|
+
const exact = new Set;
|
|
89
|
+
const prefixes = [];
|
|
90
|
+
for (const topic of topics) {
|
|
91
|
+
if (topic.endsWith("*")) {
|
|
92
|
+
prefixes.push(topic.slice(0, -1));
|
|
93
|
+
} else {
|
|
94
|
+
exact.add(topic);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const subscription = { exact, prefixes, listener };
|
|
98
|
+
subscriptions.add(subscription);
|
|
99
|
+
return () => {
|
|
100
|
+
subscriptions.delete(subscription);
|
|
101
|
+
};
|
|
102
|
+
},
|
|
103
|
+
subscriberCount: (topic) => {
|
|
104
|
+
if (topic === undefined) {
|
|
105
|
+
return subscriptions.size;
|
|
106
|
+
}
|
|
107
|
+
let count = 0;
|
|
108
|
+
for (const subscription of subscriptions) {
|
|
109
|
+
if (matches(subscription, topic)) {
|
|
110
|
+
count += 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return count;
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
// src/plugin.ts
|
|
118
|
+
import { Elysia } from "elysia";
|
|
119
|
+
var defaultResolveTopics = (context) => (context.query.topics ?? "").split(",").map((topic) => topic.trim()).filter(Boolean);
|
|
120
|
+
var sync = ({
|
|
121
|
+
hub,
|
|
122
|
+
path = "/sync",
|
|
123
|
+
resolveTopics = defaultResolveTopics,
|
|
124
|
+
heartbeatMs = 25000
|
|
125
|
+
}) => new Elysia({ name: "@absolutejs/sync" }).get(path, (context) => {
|
|
126
|
+
const topics = resolveTopics({
|
|
127
|
+
query: context.query,
|
|
128
|
+
request: context.request
|
|
129
|
+
});
|
|
130
|
+
const encoder = new TextEncoder;
|
|
131
|
+
const stream = new ReadableStream({
|
|
132
|
+
start(controller) {
|
|
133
|
+
const write = (chunk) => {
|
|
134
|
+
try {
|
|
135
|
+
controller.enqueue(encoder.encode(chunk));
|
|
136
|
+
} catch {}
|
|
137
|
+
};
|
|
138
|
+
const send = (event) => {
|
|
139
|
+
write(`data: ${JSON.stringify(event)}
|
|
140
|
+
|
|
141
|
+
`);
|
|
142
|
+
};
|
|
143
|
+
send({
|
|
144
|
+
topic: "@absolutejs/sync:open",
|
|
145
|
+
at: Date.now(),
|
|
146
|
+
payload: { topics }
|
|
147
|
+
});
|
|
148
|
+
const unsubscribe = topics.length > 0 ? hub.subscribe(topics, send) : () => {};
|
|
149
|
+
const heartbeat = setInterval(() => write(`: ping
|
|
150
|
+
|
|
151
|
+
`), heartbeatMs);
|
|
152
|
+
context.request.signal.addEventListener("abort", () => {
|
|
153
|
+
clearInterval(heartbeat);
|
|
154
|
+
unsubscribe();
|
|
155
|
+
try {
|
|
156
|
+
controller.close();
|
|
157
|
+
} catch {}
|
|
158
|
+
}, { once: true });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
return new Response(stream, {
|
|
162
|
+
headers: {
|
|
163
|
+
"cache-control": "no-cache, no-transform",
|
|
164
|
+
connection: "keep-alive",
|
|
165
|
+
"content-type": "text/event-stream"
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
export {
|
|
170
|
+
sync,
|
|
171
|
+
createWriteBehindCache,
|
|
172
|
+
createReactiveHub
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
//# debugId=2C5B85D345323A2D64756E2164756E21
|
|
176
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../src/writeBehindCache.ts", "../src/reactiveHub.ts", "../src/plugin.ts"],
|
|
4
|
+
"sourcesContent": [
|
|
5
|
+
"export type WriteBehindCacheOptions<K, V> = {\n\t/**\n\t * Read a value from the durable store on a cache miss. Called at most once per\n\t * key until the entry is evicted.\n\t */\n\tload: (key: K) => Promise<V | undefined> | V | undefined;\n\t/** Persist a value to the durable store. Runs in the background (write-behind). */\n\tpersist: (key: K, value: V) => Promise<void> | void;\n\t/** Remove a value from the durable store. */\n\tremove?: (key: K) => Promise<void> | void;\n\t/**\n\t * Coalesce writes: each key persists at most once per window. A burst of\n\t * `set`s collapses into a single durable write. Defaults to 250ms.\n\t */\n\tdebounceMs?: number;\n\t/**\n\t * After a key persists, return true to drop it from the in-memory cache so the\n\t * cache stays bounded to \"hot\" entries (e.g. evict terminal sessions). The next\n\t * `get` reloads it via `load`. Defaults to never evicting.\n\t */\n\tevict?: (value: V, key: K) => boolean;\n\t/**\n\t * Called when a background persist throws. The cache stays authoritative and the\n\t * key re-persists on its next `set`, so a transient durable-store blip does not\n\t * drop live state. Defaults to a no-op.\n\t */\n\tonPersistError?: (error: unknown, key: K) => void;\n};\n\nexport type WriteBehindCache<K, V> = {\n\t/** Cached value, or load-through from the durable store on a miss. */\n\tget: (key: K) => Promise<V | undefined>;\n\t/** Cached value only — synchronous, never touches the durable store. */\n\tpeek: (key: K) => V | undefined;\n\thas: (key: K) => boolean;\n\t/** Write to memory immediately and schedule a coalesced durable persist. */\n\tset: (key: K, value: V) => void;\n\t/** Drop from cache and the durable store. */\n\tdelete: (key: K) => Promise<void>;\n\tkeys: () => IterableIterator<K>;\n\tvalues: () => IterableIterator<V>;\n\tsize: () => number;\n\t/** Persist every pending key to the durable store now. Call on shutdown. */\n\tflush: () => Promise<void>;\n};\n\n/**\n * Wrap a durable store (Postgres, SQLite, Drizzle, Prisma, file, S3, an HTTP API …)\n * with an in-memory hot cache and write-behind persistence.\n *\n * Reads are served from memory; writes hit memory synchronously and are flushed to\n * the durable store in coalesced background batches. The durable store stays the\n * source of truth for history and cross-instance reads, while a latency-sensitive\n * hot path (a per-frame voice session, presence, cursors, game state) stays fast.\n *\n * This is the \"fast authoritative local state, durable persistence synced behind it\"\n * split a sync engine like Convex makes — without adopting a whole sync-engine\n * backend. Bring your own store via `load`/`persist`/`remove`.\n */\nexport const createWriteBehindCache = <K, V>(\n\toptions: WriteBehindCacheOptions<K, V>\n): WriteBehindCache<K, V> => {\n\tconst debounceMs = options.debounceMs ?? 250;\n\tconst cache = new Map<K, V>();\n\tconst timers = new Map<K, ReturnType<typeof setTimeout>>();\n\n\tconst persist = async (key: K) => {\n\t\ttimers.delete(key);\n\t\tconst value = cache.get(key);\n\t\tif (value === undefined) {\n\t\t\treturn;\n\t\t}\n\t\ttry {\n\t\t\tawait options.persist(key, value);\n\t\t\tif (options.evict?.(value, key)) {\n\t\t\t\tcache.delete(key);\n\t\t\t}\n\t\t} catch (error) {\n\t\t\toptions.onPersistError?.(error, key);\n\t\t}\n\t};\n\n\tconst schedulePersist = (key: K) => {\n\t\tif (timers.has(key)) {\n\t\t\treturn;\n\t\t}\n\t\ttimers.set(\n\t\t\tkey,\n\t\t\tsetTimeout(() => {\n\t\t\t\tvoid persist(key);\n\t\t\t}, debounceMs)\n\t\t);\n\t};\n\n\treturn {\n\t\tget: async (key) => {\n\t\t\tconst cached = cache.get(key);\n\t\t\tif (cached !== undefined) {\n\t\t\t\treturn cached;\n\t\t\t}\n\t\t\tconst loaded = await options.load(key);\n\t\t\tif (loaded !== undefined) {\n\t\t\t\tcache.set(key, loaded);\n\t\t\t}\n\t\t\treturn loaded;\n\t\t},\n\t\tpeek: (key) => cache.get(key),\n\t\thas: (key) => cache.has(key),\n\t\tset: (key, value) => {\n\t\t\tcache.set(key, value);\n\t\t\tschedulePersist(key);\n\t\t},\n\t\tdelete: async (key) => {\n\t\t\tconst timer = timers.get(key);\n\t\t\tif (timer) {\n\t\t\t\tclearTimeout(timer);\n\t\t\t\ttimers.delete(key);\n\t\t\t}\n\t\t\tcache.delete(key);\n\t\t\tawait options.remove?.(key);\n\t\t},\n\t\tkeys: () => cache.keys(),\n\t\tvalues: () => cache.values(),\n\t\tsize: () => cache.size,\n\t\tflush: async () => {\n\t\t\tfor (const timer of timers.values()) {\n\t\t\t\tclearTimeout(timer);\n\t\t\t}\n\t\t\ttimers.clear();\n\t\t\tawait Promise.all([...cache.keys()].map((key) => persist(key)));\n\t\t}\n\t};\n};\n",
|
|
6
|
+
"export type ReactiveEvent<TPayload = unknown> = {\n\ttopic: string;\n\tat: number;\n\tpayload?: TPayload;\n};\n\nexport type ReactiveListener<TPayload = unknown> = (\n\tevent: ReactiveEvent<TPayload>\n) => void;\n\nexport type ReactiveHub = {\n\t/**\n\t * Notify every subscriber of `topic` (and any prefix-wildcard subscriber that\n\t * matches it). Call this from a mutation after the durable write commits.\n\t */\n\tpublish: (topic: string, payload?: unknown) => void;\n\t/**\n\t * Listen on one or more topics. A topic ending in `*` matches every topic that\n\t * starts with the prefix before it (e.g. `voice:session:*`). Returns an\n\t * unsubscribe function.\n\t */\n\tsubscribe: (topics: string[], listener: ReactiveListener) => () => void;\n\t/** Number of active subscribers, optionally for a single exact topic. */\n\tsubscriberCount: (topic?: string) => number;\n};\n\ntype Subscription = {\n\texact: Set<string>;\n\tprefixes: string[];\n\tlistener: ReactiveListener;\n};\n\n/**\n * An in-memory topic pub/sub for reactive, push-on-change updates.\n *\n * The pattern that replaces polling: a query/widget subscribes to the topics its\n * data depends on; a mutation `publish`es those topics after it writes; subscribers\n * are notified immediately and refetch (or receive the pushed payload) — instead of\n * every client hammering the server on a timer.\n *\n * Dependencies are explicit (you name the topics) rather than auto-tracked from a\n * query's read set — deliberately small, with no sandbox or query interception.\n * Pair it with the {@link sync} Elysia plugin to stream events to browsers over SSE.\n */\nexport const createReactiveHub = (): ReactiveHub => {\n\tconst subscriptions = new Set<Subscription>();\n\n\tconst matches = (subscription: Subscription, topic: string) => {\n\t\tif (subscription.exact.has(topic)) {\n\t\t\treturn true;\n\t\t}\n\t\treturn subscription.prefixes.some((prefix) => topic.startsWith(prefix));\n\t};\n\n\treturn {\n\t\tpublish: (topic, payload) => {\n\t\t\tconst event: ReactiveEvent = { topic, at: Date.now(), payload };\n\t\t\tfor (const subscription of subscriptions) {\n\t\t\t\tif (matches(subscription, topic)) {\n\t\t\t\t\tsubscription.listener(event);\n\t\t\t\t}\n\t\t\t}\n\t\t},\n\t\tsubscribe: (topics, listener) => {\n\t\t\tconst exact = new Set<string>();\n\t\t\tconst prefixes: string[] = [];\n\t\t\tfor (const topic of topics) {\n\t\t\t\tif (topic.endsWith('*')) {\n\t\t\t\t\tprefixes.push(topic.slice(0, -1));\n\t\t\t\t} else {\n\t\t\t\t\texact.add(topic);\n\t\t\t\t}\n\t\t\t}\n\t\t\tconst subscription: Subscription = { exact, prefixes, listener };\n\t\t\tsubscriptions.add(subscription);\n\t\t\treturn () => {\n\t\t\t\tsubscriptions.delete(subscription);\n\t\t\t};\n\t\t},\n\t\tsubscriberCount: (topic) => {\n\t\t\tif (topic === undefined) {\n\t\t\t\treturn subscriptions.size;\n\t\t\t}\n\t\t\tlet count = 0;\n\t\t\tfor (const subscription of subscriptions) {\n\t\t\t\tif (matches(subscription, topic)) {\n\t\t\t\t\tcount += 1;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn count;\n\t\t}\n\t};\n};\n",
|
|
7
|
+
"import { Elysia } from 'elysia';\nimport type { ReactiveEvent, ReactiveHub } from './reactiveHub';\n\nexport type SyncRequestContext = {\n\tquery: Record<string, string | undefined>;\n\trequest: Request;\n};\n\nexport type SyncPluginOptions = {\n\thub: ReactiveHub;\n\t/** Route the SSE stream is served from. Defaults to `/sync`. */\n\tpath?: string;\n\t/**\n\t * Which topics a connection subscribes to. Defaults to a comma-separated\n\t * `?topics=a,b,c` query param. Override to derive topics from the session,\n\t * params, or auth instead of trusting the client.\n\t */\n\tresolveTopics?: (context: SyncRequestContext) => string[];\n\t/**\n\t * Server→client heartbeat comment, so idle proxies don't drop the SSE stream.\n\t * Defaults to 25000ms.\n\t */\n\theartbeatMs?: number;\n};\n\nconst defaultResolveTopics = (context: SyncRequestContext) =>\n\t(context.query.topics ?? '')\n\t\t.split(',')\n\t\t.map((topic) => topic.trim())\n\t\t.filter(Boolean);\n\n/**\n * Elysia plugin that streams {@link ReactiveHub} events to browsers over Server-Sent\n * Events. Mount it once, point {@link createSyncSubscriber} at the same path, and\n * `hub.publish(topic)` from your mutations — subscribed clients are notified the\n * moment data changes, so they can refetch (or read the pushed payload) instead of\n * polling on a timer.\n */\nexport const sync = ({\n\thub,\n\tpath = '/sync',\n\tresolveTopics = defaultResolveTopics,\n\theartbeatMs = 25_000\n}: SyncPluginOptions) =>\n\tnew Elysia({ name: '@absolutejs/sync' }).get(path, (context) => {\n\t\tconst topics = resolveTopics({\n\t\t\tquery: context.query as Record<string, string | undefined>,\n\t\t\trequest: context.request\n\t\t});\n\t\tconst encoder = new TextEncoder();\n\n\t\tconst stream = new ReadableStream<Uint8Array>({\n\t\t\tstart(controller) {\n\t\t\t\tconst write = (chunk: string) => {\n\t\t\t\t\ttry {\n\t\t\t\t\t\tcontroller.enqueue(encoder.encode(chunk));\n\t\t\t\t\t} catch {\n\t\t\t\t\t\t// controller already closed by an abort race\n\t\t\t\t\t}\n\t\t\t\t};\n\t\t\t\tconst send = (event: ReactiveEvent) => {\n\t\t\t\t\twrite(`data: ${JSON.stringify(event)}\\n\\n`);\n\t\t\t\t};\n\n\t\t\t\tsend({\n\t\t\t\t\ttopic: '@absolutejs/sync:open',\n\t\t\t\t\tat: Date.now(),\n\t\t\t\t\tpayload: { topics }\n\t\t\t\t});\n\n\t\t\t\tconst unsubscribe =\n\t\t\t\t\ttopics.length > 0 ? hub.subscribe(topics, send) : () => {};\n\t\t\t\tconst heartbeat = setInterval(\n\t\t\t\t\t() => write(': ping\\n\\n'),\n\t\t\t\t\theartbeatMs\n\t\t\t\t);\n\n\t\t\t\tcontext.request.signal.addEventListener(\n\t\t\t\t\t'abort',\n\t\t\t\t\t() => {\n\t\t\t\t\t\tclearInterval(heartbeat);\n\t\t\t\t\t\tunsubscribe();\n\t\t\t\t\t\ttry {\n\t\t\t\t\t\t\tcontroller.close();\n\t\t\t\t\t\t} catch {\n\t\t\t\t\t\t\t// already closed\n\t\t\t\t\t\t}\n\t\t\t\t\t},\n\t\t\t\t\t{ once: true }\n\t\t\t\t);\n\t\t\t}\n\t\t});\n\n\t\treturn new Response(stream, {\n\t\t\theaders: {\n\t\t\t\t'cache-control': 'no-cache, no-transform',\n\t\t\t\tconnection: 'keep-alive',\n\t\t\t\t'content-type': 'text/event-stream'\n\t\t\t}\n\t\t});\n\t});\n"
|
|
8
|
+
],
|
|
9
|
+
"mappings": "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",
|
|
10
|
+
"debugId": "2C5B85D345323A2D64756E2164756E21",
|
|
11
|
+
"names": []
|
|
12
|
+
}
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { Elysia } from 'elysia';
|
|
2
|
+
import type { ReactiveHub } from './reactiveHub';
|
|
3
|
+
export type SyncRequestContext = {
|
|
4
|
+
query: Record<string, string | undefined>;
|
|
5
|
+
request: Request;
|
|
6
|
+
};
|
|
7
|
+
export type SyncPluginOptions = {
|
|
8
|
+
hub: ReactiveHub;
|
|
9
|
+
/** Route the SSE stream is served from. Defaults to `/sync`. */
|
|
10
|
+
path?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Which topics a connection subscribes to. Defaults to a comma-separated
|
|
13
|
+
* `?topics=a,b,c` query param. Override to derive topics from the session,
|
|
14
|
+
* params, or auth instead of trusting the client.
|
|
15
|
+
*/
|
|
16
|
+
resolveTopics?: (context: SyncRequestContext) => string[];
|
|
17
|
+
/**
|
|
18
|
+
* Server→client heartbeat comment, so idle proxies don't drop the SSE stream.
|
|
19
|
+
* Defaults to 25000ms.
|
|
20
|
+
*/
|
|
21
|
+
heartbeatMs?: number;
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* Elysia plugin that streams {@link ReactiveHub} events to browsers over Server-Sent
|
|
25
|
+
* Events. Mount it once, point {@link createSyncSubscriber} at the same path, and
|
|
26
|
+
* `hub.publish(topic)` from your mutations — subscribed clients are notified the
|
|
27
|
+
* moment data changes, so they can refetch (or read the pushed payload) instead of
|
|
28
|
+
* polling on a timer.
|
|
29
|
+
*/
|
|
30
|
+
export declare const sync: ({ hub, path, resolveTopics, heartbeatMs }: SyncPluginOptions) => Elysia<"", {
|
|
31
|
+
decorator: {};
|
|
32
|
+
store: {};
|
|
33
|
+
derive: {};
|
|
34
|
+
resolve: {};
|
|
35
|
+
}, {
|
|
36
|
+
typebox: {};
|
|
37
|
+
error: {};
|
|
38
|
+
}, {
|
|
39
|
+
schema: {};
|
|
40
|
+
standaloneSchema: {};
|
|
41
|
+
macro: {};
|
|
42
|
+
macroFn: {};
|
|
43
|
+
parser: {};
|
|
44
|
+
response: {};
|
|
45
|
+
}, {
|
|
46
|
+
[x: string]: {
|
|
47
|
+
get: {
|
|
48
|
+
body: unknown;
|
|
49
|
+
params: {};
|
|
50
|
+
query: unknown;
|
|
51
|
+
headers: unknown;
|
|
52
|
+
response: {
|
|
53
|
+
200: Response;
|
|
54
|
+
};
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
}, {
|
|
58
|
+
derive: {};
|
|
59
|
+
resolve: {};
|
|
60
|
+
schema: {};
|
|
61
|
+
standaloneSchema: {};
|
|
62
|
+
response: {};
|
|
63
|
+
}, {
|
|
64
|
+
derive: {};
|
|
65
|
+
resolve: {};
|
|
66
|
+
schema: {};
|
|
67
|
+
standaloneSchema: {};
|
|
68
|
+
response: {};
|
|
69
|
+
}>;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export type ReactiveEvent<TPayload = unknown> = {
|
|
2
|
+
topic: string;
|
|
3
|
+
at: number;
|
|
4
|
+
payload?: TPayload;
|
|
5
|
+
};
|
|
6
|
+
export type ReactiveListener<TPayload = unknown> = (event: ReactiveEvent<TPayload>) => void;
|
|
7
|
+
export type ReactiveHub = {
|
|
8
|
+
/**
|
|
9
|
+
* Notify every subscriber of `topic` (and any prefix-wildcard subscriber that
|
|
10
|
+
* matches it). Call this from a mutation after the durable write commits.
|
|
11
|
+
*/
|
|
12
|
+
publish: (topic: string, payload?: unknown) => void;
|
|
13
|
+
/**
|
|
14
|
+
* Listen on one or more topics. A topic ending in `*` matches every topic that
|
|
15
|
+
* starts with the prefix before it (e.g. `voice:session:*`). Returns an
|
|
16
|
+
* unsubscribe function.
|
|
17
|
+
*/
|
|
18
|
+
subscribe: (topics: string[], listener: ReactiveListener) => () => void;
|
|
19
|
+
/** Number of active subscribers, optionally for a single exact topic. */
|
|
20
|
+
subscriberCount: (topic?: string) => number;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* An in-memory topic pub/sub for reactive, push-on-change updates.
|
|
24
|
+
*
|
|
25
|
+
* The pattern that replaces polling: a query/widget subscribes to the topics its
|
|
26
|
+
* data depends on; a mutation `publish`es those topics after it writes; subscribers
|
|
27
|
+
* are notified immediately and refetch (or receive the pushed payload) — instead of
|
|
28
|
+
* every client hammering the server on a timer.
|
|
29
|
+
*
|
|
30
|
+
* Dependencies are explicit (you name the topics) rather than auto-tracked from a
|
|
31
|
+
* query's read set — deliberately small, with no sandbox or query interception.
|
|
32
|
+
* Pair it with the {@link sync} Elysia plugin to stream events to browsers over SSE.
|
|
33
|
+
*/
|
|
34
|
+
export declare const createReactiveHub: () => ReactiveHub;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export type WriteBehindCacheOptions<K, V> = {
|
|
2
|
+
/**
|
|
3
|
+
* Read a value from the durable store on a cache miss. Called at most once per
|
|
4
|
+
* key until the entry is evicted.
|
|
5
|
+
*/
|
|
6
|
+
load: (key: K) => Promise<V | undefined> | V | undefined;
|
|
7
|
+
/** Persist a value to the durable store. Runs in the background (write-behind). */
|
|
8
|
+
persist: (key: K, value: V) => Promise<void> | void;
|
|
9
|
+
/** Remove a value from the durable store. */
|
|
10
|
+
remove?: (key: K) => Promise<void> | void;
|
|
11
|
+
/**
|
|
12
|
+
* Coalesce writes: each key persists at most once per window. A burst of
|
|
13
|
+
* `set`s collapses into a single durable write. Defaults to 250ms.
|
|
14
|
+
*/
|
|
15
|
+
debounceMs?: number;
|
|
16
|
+
/**
|
|
17
|
+
* After a key persists, return true to drop it from the in-memory cache so the
|
|
18
|
+
* cache stays bounded to "hot" entries (e.g. evict terminal sessions). The next
|
|
19
|
+
* `get` reloads it via `load`. Defaults to never evicting.
|
|
20
|
+
*/
|
|
21
|
+
evict?: (value: V, key: K) => boolean;
|
|
22
|
+
/**
|
|
23
|
+
* Called when a background persist throws. The cache stays authoritative and the
|
|
24
|
+
* key re-persists on its next `set`, so a transient durable-store blip does not
|
|
25
|
+
* drop live state. Defaults to a no-op.
|
|
26
|
+
*/
|
|
27
|
+
onPersistError?: (error: unknown, key: K) => void;
|
|
28
|
+
};
|
|
29
|
+
export type WriteBehindCache<K, V> = {
|
|
30
|
+
/** Cached value, or load-through from the durable store on a miss. */
|
|
31
|
+
get: (key: K) => Promise<V | undefined>;
|
|
32
|
+
/** Cached value only — synchronous, never touches the durable store. */
|
|
33
|
+
peek: (key: K) => V | undefined;
|
|
34
|
+
has: (key: K) => boolean;
|
|
35
|
+
/** Write to memory immediately and schedule a coalesced durable persist. */
|
|
36
|
+
set: (key: K, value: V) => void;
|
|
37
|
+
/** Drop from cache and the durable store. */
|
|
38
|
+
delete: (key: K) => Promise<void>;
|
|
39
|
+
keys: () => IterableIterator<K>;
|
|
40
|
+
values: () => IterableIterator<V>;
|
|
41
|
+
size: () => number;
|
|
42
|
+
/** Persist every pending key to the durable store now. Call on shutdown. */
|
|
43
|
+
flush: () => Promise<void>;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* Wrap a durable store (Postgres, SQLite, Drizzle, Prisma, file, S3, an HTTP API …)
|
|
47
|
+
* with an in-memory hot cache and write-behind persistence.
|
|
48
|
+
*
|
|
49
|
+
* Reads are served from memory; writes hit memory synchronously and are flushed to
|
|
50
|
+
* the durable store in coalesced background batches. The durable store stays the
|
|
51
|
+
* source of truth for history and cross-instance reads, while a latency-sensitive
|
|
52
|
+
* hot path (a per-frame voice session, presence, cursors, game state) stays fast.
|
|
53
|
+
*
|
|
54
|
+
* This is the "fast authoritative local state, durable persistence synced behind it"
|
|
55
|
+
* split a sync engine like Convex makes — without adopting a whole sync-engine
|
|
56
|
+
* backend. Bring your own store via `load`/`persist`/`remove`.
|
|
57
|
+
*/
|
|
58
|
+
export declare const createWriteBehindCache: <K, V>(options: WriteBehindCacheOptions<K, V>) => WriteBehindCache<K, V>;
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@absolutejs/sync",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Lightweight reactive-push and write-behind-cache primitives for Elysia and the AbsoluteJS ecosystem — kill polling and keep a remote store off your hot path, without adopting a whole sync-engine backend.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"module": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js",
|
|
13
|
+
"default": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"./client": {
|
|
16
|
+
"types": "./dist/client/index.d.ts",
|
|
17
|
+
"import": "./dist/client/index.js",
|
|
18
|
+
"default": "./dist/client/index.js"
|
|
19
|
+
}
|
|
20
|
+
},
|
|
21
|
+
"files": ["dist", "README.md"],
|
|
22
|
+
"publishConfig": {
|
|
23
|
+
"access": "public"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "rm -rf dist && bun build src/index.ts --outdir dist --sourcemap --target=bun --external elysia && bun build src/client/index.ts --outdir dist/client --sourcemap --target=browser --format=esm && tsc --project tsconfig.build.json",
|
|
27
|
+
"test": "bun test",
|
|
28
|
+
"typecheck": "tsc --noEmit",
|
|
29
|
+
"format": "prettier --write \"./**/*.{ts,json,md}\"",
|
|
30
|
+
"release": "bun run format && bun run build && bun publish"
|
|
31
|
+
},
|
|
32
|
+
"keywords": [
|
|
33
|
+
"absolutejs",
|
|
34
|
+
"elysia",
|
|
35
|
+
"reactive",
|
|
36
|
+
"sse",
|
|
37
|
+
"realtime",
|
|
38
|
+
"write-behind",
|
|
39
|
+
"cache",
|
|
40
|
+
"sync"
|
|
41
|
+
],
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"elysia": ">= 1.4.26"
|
|
44
|
+
},
|
|
45
|
+
"peerDependenciesMeta": {
|
|
46
|
+
"elysia": {
|
|
47
|
+
"optional": true
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@types/bun": "latest",
|
|
52
|
+
"elysia": "latest",
|
|
53
|
+
"prettier": "latest",
|
|
54
|
+
"typescript": "latest"
|
|
55
|
+
}
|
|
56
|
+
}
|