@spooky-sync/core 0.0.1-canary.280 → 0.0.1-canary.282

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.
@@ -0,0 +1,73 @@
1
+ //#region src/push/types.ts
2
+ /**
3
+ * Web Push wire types shared by the page (`db.webPush`), the service worker
4
+ * bridge (`@spooky-sync/core/sw`) and the live feed. Pure: types plus a few
5
+ * dependency-free helpers, safe in any context.
6
+ *
7
+ * `PushPayload` mirrors `packages/push-core/src/config.rs` (`PushPayload`,
8
+ * `PayloadKind`, `Op`, `NotificationTemplate`). Keep the two in step.
9
+ */
10
+ /** Payload format version this build understands. */
11
+ const PUSH_PAYLOAD_VERSION = 1;
12
+ /** `urlBase64ToUint8Array`: the VAPID key as `applicationServerKey` wants it. */
13
+ function base64UrlToBytes(value) {
14
+ const padded = value.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - value.length % 4) % 4);
15
+ const raw = typeof atob === "function" ? atob(padded) : decodeBase64Fallback(padded);
16
+ const out = new Uint8Array(raw.length);
17
+ for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
18
+ return out;
19
+ }
20
+ function bytesToBase64Url(bytes) {
21
+ const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
22
+ let raw = "";
23
+ for (let i = 0; i < view.length; i++) raw += String.fromCharCode(view[i]);
24
+ return (typeof btoa === "function" ? btoa(raw) : encodeBase64Fallback(raw)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
25
+ }
26
+ function sameBytes(a, b) {
27
+ if (!a || !b) return false;
28
+ const x = a instanceof Uint8Array ? a : new Uint8Array(a);
29
+ const y = b instanceof Uint8Array ? b : new Uint8Array(b);
30
+ if (x.length !== y.length) return false;
31
+ for (let i = 0; i < x.length; i++) if (x[i] !== y[i]) return false;
32
+ return true;
33
+ }
34
+ /** A payload this build can act on (`v` known, `kind` set). */
35
+ function isPushPayload(value) {
36
+ if (!value || typeof value !== "object") return false;
37
+ const v = value;
38
+ return v.v === PUSH_PAYLOAD_VERSION && (v.kind === "rule" || v.kind === "message");
39
+ }
40
+ const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
41
+ function decodeBase64Fallback(input) {
42
+ const clean = input.replace(/=+$/, "");
43
+ let out = "";
44
+ let buffer = 0;
45
+ let bits = 0;
46
+ for (const ch of clean) {
47
+ const idx = B64.indexOf(ch);
48
+ if (idx < 0) continue;
49
+ buffer = buffer << 6 | idx;
50
+ bits += 6;
51
+ if (bits >= 8) {
52
+ bits -= 8;
53
+ out += String.fromCharCode(buffer >> bits & 255);
54
+ }
55
+ }
56
+ return out;
57
+ }
58
+ function encodeBase64Fallback(raw) {
59
+ let out = "";
60
+ for (let i = 0; i < raw.length; i += 3) {
61
+ const a = raw.charCodeAt(i);
62
+ const b = i + 1 < raw.length ? raw.charCodeAt(i + 1) : 0;
63
+ const c = i + 2 < raw.length ? raw.charCodeAt(i + 2) : 0;
64
+ const n = a << 16 | b << 8 | c;
65
+ out += B64[n >> 18 & 63] + B64[n >> 12 & 63];
66
+ out += i + 1 < raw.length ? B64[n >> 6 & 63] : "=";
67
+ out += i + 2 < raw.length ? B64[n & 63] : "=";
68
+ }
69
+ return out;
70
+ }
71
+
72
+ //#endregion
73
+ export { sameBytes as a, isPushPayload as i, base64UrlToBytes as n, bytesToBase64Url as r, PUSH_PAYLOAD_VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@spooky-sync/core",
3
- "version": "0.0.1-canary.280",
3
+ "version": "0.0.1-canary.282",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "main": "./dist/index.js",
@@ -13,12 +13,24 @@
13
13
  "./otel": {
14
14
  "types": "./dist/otel/index.d.ts",
15
15
  "import": "./dist/otel/index.js"
16
+ },
17
+ "./pure": {
18
+ "types": "./dist/pure.d.ts",
19
+ "import": "./dist/pure.js"
20
+ },
21
+ "./live": {
22
+ "types": "./dist/live.d.ts",
23
+ "import": "./dist/live.js"
24
+ },
25
+ "./sw": {
26
+ "types": "./dist/sw.d.ts",
27
+ "import": "./dist/sw.js"
16
28
  }
17
29
  },
18
30
  "scripts": {
19
31
  "dev": "tsdown --watch",
20
32
  "dev:example": "pnpm dev",
21
- "build": "tsdown && node scripts/check-broker-bundle.mjs",
33
+ "build": "tsdown && node scripts/check-broker-bundle.mjs && node scripts/check-worker-entries.mjs",
22
34
  "test": "vitest",
23
35
  "test:run": "vitest run",
24
36
  "test:coverage": "vitest run --coverage",
@@ -63,8 +75,8 @@
63
75
  }
64
76
  },
65
77
  "dependencies": {
66
- "@spooky-sync/query-builder": "0.0.1-canary.280",
67
- "@spooky-sync/ssp-wasm": "0.0.1-canary.280",
78
+ "@spooky-sync/query-builder": "0.0.1-canary.282",
79
+ "@spooky-sync/ssp-wasm": "0.0.1-canary.282",
68
80
  "@sqlite.org/sqlite-wasm": "3.53.0-build1",
69
81
  "@surrealdb/wasm": "^3.0.3",
70
82
  "blurhash": "^2.0.5",
@@ -0,0 +1,172 @@
1
+ // Build gate for the wasm-free subpath entries: dist/pure.js, dist/live.js and
2
+ // dist/sw.js (`@spooky-sync/core/pure`, `/live`, `/sw`). They are imported by
3
+ // service workers, dedicated workers and Node, so across each entry and every
4
+ // chunk it reaches:
5
+ // - no import of a wasm engine, the circuit, CRDTs or the logger;
6
+ // - no reference to `window`, `document` or `localStorage` (a service worker
7
+ // has none of them; an access at module top level would throw on import);
8
+ // - `/sw` does not even pull in the `surrealdb` SDK.
9
+ // Each entry is then really imported in this Node process (no DOM globals)
10
+ // to catch top-level side effects the static scan cannot see. Prints the
11
+ // gzipped size of each entry (own code, and fully bundled with its npm
12
+ // dependencies when rolldown is reachable). Run after `tsdown` in the build.
13
+ import { readFileSync, existsSync } from 'node:fs';
14
+ import { resolve, dirname, join } from 'node:path';
15
+ import { fileURLToPath, pathToFileURL } from 'node:url';
16
+ import { gzipSync } from 'node:zlib';
17
+ import { createRequire } from 'node:module';
18
+
19
+ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..');
20
+ const dist = join(root, 'dist');
21
+
22
+ const ENTRIES = ['pure.js', 'live.js', 'sw.js'];
23
+ const FORBIDDEN_IMPORTS = [
24
+ '@spooky-sync/ssp-wasm',
25
+ '@surrealdb/wasm',
26
+ '@sqlite.org/sqlite-wasm',
27
+ 'loro-crdt',
28
+ 'pino',
29
+ 'blurhash',
30
+ ];
31
+ const FORBIDDEN_PER_ENTRY = { 'sw.js': ['surrealdb'] };
32
+ const FORBIDDEN_GLOBALS = ['window', 'document', 'localStorage'];
33
+
34
+ const importRe =
35
+ /(?:import|export)\s*(?:[^'"`;]*?\sfrom\s*)?["']([^"']+)["']|import\(\s*["']([^"']+)["']\s*\)/g;
36
+
37
+ /** Blank out comments and string/template literals so identifier scans see code only. */
38
+ function codeOnly(src) {
39
+ let out = '';
40
+ let i = 0;
41
+ while (i < src.length) {
42
+ const c = src[i];
43
+ const n = src[i + 1];
44
+ if (c === '/' && n === '/') {
45
+ while (i < src.length && src[i] !== '\n') i++;
46
+ continue;
47
+ }
48
+ if (c === '/' && n === '*') {
49
+ i += 2;
50
+ while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i++;
51
+ i += 2;
52
+ continue;
53
+ }
54
+ if (c === '"' || c === "'" || c === '`') {
55
+ const q = c;
56
+ i++;
57
+ while (i < src.length && src[i] !== q) i += src[i] === '\\' ? 2 : 1;
58
+ i++;
59
+ out += '""';
60
+ continue;
61
+ }
62
+ out += c;
63
+ i++;
64
+ }
65
+ return out;
66
+ }
67
+
68
+ function walk(entry) {
69
+ const seen = new Set();
70
+ const bare = new Set();
71
+ const stack = [join(dist, entry)];
72
+ while (stack.length > 0) {
73
+ const file = stack.pop();
74
+ if (seen.has(file)) continue;
75
+ seen.add(file);
76
+ if (!existsSync(file)) throw new Error(`${entry}: missing ${file}`);
77
+ const src = readFileSync(file, 'utf8');
78
+ for (const m of src.matchAll(importRe)) {
79
+ const spec = m[1] ?? m[2];
80
+ if (spec.startsWith('.')) stack.push(resolve(dirname(file), spec));
81
+ else bare.add(spec);
82
+ }
83
+ }
84
+ return { files: [...seen], bare: [...bare] };
85
+ }
86
+
87
+ const problems = [];
88
+ const sizes = [];
89
+
90
+ for (const entry of ENTRIES) {
91
+ let graph;
92
+ try {
93
+ graph = walk(entry);
94
+ } catch (error) {
95
+ problems.push(String(error.message ?? error));
96
+ continue;
97
+ }
98
+ const forbidden = [...FORBIDDEN_IMPORTS, ...(FORBIDDEN_PER_ENTRY[entry] ?? [])];
99
+ for (const spec of graph.bare) {
100
+ if (forbidden.some((f) => spec === f || spec.startsWith(`${f}/`)))
101
+ problems.push(`${entry} imports ${spec}`);
102
+ }
103
+ let own = '';
104
+ for (const file of graph.files) {
105
+ const src = readFileSync(file, 'utf8');
106
+ own += src;
107
+ if (/\.wasm\b/.test(src))
108
+ problems.push(`${entry}: ${file.slice(dist.length + 1)} references a .wasm file`);
109
+ const code = codeOnly(src);
110
+ for (const name of FORBIDDEN_GLOBALS) {
111
+ // `x.window` (a property) is fine; a bare identifier is not.
112
+ const re = new RegExp(`(?<![.\\w$])${name}(?![\\w$])`);
113
+ if (re.test(code))
114
+ problems.push(`${entry}: ${file.slice(dist.length + 1)} references \`${name}\``);
115
+ }
116
+ }
117
+ sizes.push({
118
+ entry,
119
+ files: graph.files.length,
120
+ raw: Buffer.byteLength(own),
121
+ gzip: gzipSync(own).length,
122
+ deps: graph.bare,
123
+ });
124
+ }
125
+
126
+ // Import each entry for real: top-level code must run without DOM globals.
127
+ for (const entry of ENTRIES) {
128
+ try {
129
+ await import(pathToFileURL(join(dist, entry)).href);
130
+ } catch (error) {
131
+ problems.push(`${entry} fails to import in Node: ${error?.message ?? error}`);
132
+ }
133
+ }
134
+
135
+ // Fully bundled size (entry + npm deps, minified), when rolldown is reachable
136
+ // through tsdown. Informational only.
137
+ async function bundledGzip(entry) {
138
+ try {
139
+ const req = createRequire(createRequire(join(root, 'package.json')).resolve('tsdown'));
140
+ const { rolldown } = await import(pathToFileURL(req.resolve('rolldown')).href);
141
+ const bundle = await rolldown({
142
+ input: join(dist, entry),
143
+ platform: 'browser',
144
+ logLevel: 'silent',
145
+ });
146
+ const { output } = await bundle.generate({ format: 'esm', minify: true });
147
+ await bundle.close();
148
+ const code = output.map((o) => o.code ?? '').join('');
149
+ return gzipSync(code).length;
150
+ } catch {
151
+ return null;
152
+ }
153
+ }
154
+
155
+ for (const s of sizes) s.bundled = await bundledGzip(s.entry);
156
+
157
+ const kb = (n) => `${(n / 1024).toFixed(2)} kB`;
158
+ for (const s of sizes) {
159
+ const bundled = s.bundled === null ? '' : `, bundled with deps + minified ${kb(s.bundled)} gzip`;
160
+ console.log(
161
+ `check-worker-entries: ${s.entry.padEnd(8)} ${kb(s.raw)} (${kb(s.gzip)} gzip, ${s.files} file${s.files === 1 ? '' : 's'}; deps: ${s.deps.join(', ') || 'none'})${bundled}`
162
+ );
163
+ }
164
+
165
+ if (problems.length > 0) {
166
+ for (const p of problems) console.error(`check-worker-entries: ${p}`);
167
+ console.error(
168
+ 'The /pure, /live and /sw entries must stay free of wasm, the logger and DOM globals.'
169
+ );
170
+ process.exit(1);
171
+ }
172
+ console.log('check-worker-entries: ok');
@@ -1,6 +1,6 @@
1
- import { RecordId, type Uuid } from 'surrealdb';
1
+ import type { Uuid } from 'surrealdb';
2
2
  import type { SchemaStructure } from '@spooky-sync/query-builder';
3
- import type { InlineRow, Sp00kyConfig, PersistenceClient } from '../types';
3
+ import type { Sp00kyConfig, PersistenceClient } from '../types';
4
4
  import type { Logger } from '../services/logger/index';
5
5
  import { createLogger } from '../services/logger/index';
6
6
  import { ConnectionSupervisor, LocalMigrator, RemoteDatabaseService, createLocalEngine } from '../services/database/index';
@@ -27,6 +27,8 @@ import type { Adapters } from '../kernel/interpreter';
27
27
  import type { RuntimeEvent } from '../kernel/events';
28
28
  import type { ServiceCalls } from '../kernel/effects';
29
29
  import { encodeRecordId } from '../utils/index';
30
+ import { sha256Hex } from '../utils/sha256';
31
+ import { hashOfEdge, rowOfEdge } from '../utils/edges';
30
32
  import { mintMutationId } from '../mutation/mutation-id';
31
33
  import { BucketHandle, bucketContentToBlob } from './bucket-handle';
32
34
 
@@ -48,13 +50,9 @@ export function writeBootBucketHint(bucketId: string): void {
48
50
  }
49
51
  }
50
52
 
51
- export async function sha256Hex(input: string): Promise<string> {
52
- const bytes = new TextEncoder().encode(input);
53
- const digest = await crypto.subtle.digest('SHA-256', bytes);
54
- return Array.from(new Uint8Array(digest))
55
- .map((b) => b.toString(16).padStart(2, '0'))
56
- .join('');
57
- }
53
+ // Moved to a pure module so `@spooky-sync/core/pure` and the live feed share
54
+ // it; re-exported here for existing imports.
55
+ export { sha256Hex };
58
56
 
59
57
  export function mintSalt(): string {
60
58
  const c = (globalThis as { crypto?: Crypto }).crypto;
@@ -208,35 +206,9 @@ export function leaderToRuntime(msg: { type: string; [k: string]: unknown }): un
208
206
  }
209
207
  }
210
208
 
211
- /** The hash a `_00_list_ref` edge belongs to: the id part of its `in` (`_00_query:<hash>`). */
212
- export function hashOfEdge(value: unknown): string | null {
213
- const inId = (value as { in?: unknown } | null)?.in;
214
- if (!inId) return null;
215
- const str = inId instanceof RecordId ? String(inId.id) : String(inId).replace(/^_00_query:/, '');
216
- return str.length > 0 ? str : null;
217
- }
218
-
219
- /**
220
- * The row an edge notification carries, when the subscription joined it on.
221
- *
222
- * Without `FETCH out` the edge's `out` is a record id and there is nothing to
223
- * land, so this returns null and the caller falls back to fetching the body.
224
- * It also returns null for a row the session may not read (the join yields
225
- * `out: null`) and for an edge whose target has been deleted.
226
- */
227
- export function rowOfEdge(value: unknown): InlineRow | null {
228
- const edge = value as { out?: unknown; version?: unknown } | null;
229
- const out = edge?.out;
230
- if (!out || typeof out !== 'object' || Array.isArray(out) || out instanceof RecordId) return null;
231
- // Must be a real RecordId: the landing path keys off `row.id` being one, and
232
- // would otherwise record the version for a body it never wrote.
233
- const rid = (out as { id?: unknown }).id;
234
- if (!(rid instanceof RecordId)) return null;
235
- const id = encodeRecordId(rid);
236
- const version = typeof edge?.version === 'number' ? edge.version : null;
237
- if (version === null) return null;
238
- return { id, version, record: out as Record<string, unknown> };
239
- }
209
+ // Edge decoding lives in a pure module (the wasm-free live feed uses it too);
210
+ // re-exported here for existing imports.
211
+ export { hashOfEdge, rowOfEdge };
240
212
 
241
213
  /** Build the adapters the interpreter drives from the services. */
242
214
  export function createAdapters<S extends SchemaStructure>(config: Sp00kyConfig<S>, s: Services<S>, host: ServiceHost, lateModules: () => { init(): void }[]): Adapters {
@@ -34,6 +34,7 @@ import { FeatureFlagModule, FeatureFlagHandle } from '../modules/feature-flag/in
34
34
  import type { FeatureFlagOptions, FeatureFlagOverride } from '../modules/feature-flag/index';
35
35
  import { AppReleaseModule, AppReleaseHandle } from '../modules/app-release/index';
36
36
  import type { AppReleaseOptions } from '../modules/app-release/index';
37
+ import { WebPushModule } from '../modules/web-push/index';
37
38
  import { DevToolsService } from '../modules/devtools/index';
38
39
  import type { DevToolsQuerySource } from '../modules/devtools/index';
39
40
  import { parseQueryParams, generateId } from '../utils/index';
@@ -73,6 +74,8 @@ export interface Sp00kyClientDeps<S extends SchemaStructure> {
73
74
  export class Sp00kyClient<S extends SchemaStructure> {
74
75
  public readonly auth: AuthService<S>;
75
76
  public readonly streamProcessor: StreamProcessorService;
77
+ /** Web Push for this browser: subscribe, devices, self-pushes, service worker bridge. */
78
+ public readonly webPush: WebPushModule;
76
79
 
77
80
  private readonly services: Services<S>;
78
81
  private readonly env: SagaEnv;
@@ -139,6 +142,16 @@ export class Sp00kyClient<S extends SchemaStructure> {
139
142
  const remoteRegisterPort = { enqueueDownEvent: () => void this.runtime.dispatch({ type: 'EnsureRegistered' }) };
140
143
  this.featureFlags = new FeatureFlagModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
141
144
  this.appReleases = new AppReleaseModule<S>({ dataModule: liveQueryPort, sync: remoteRegisterPort, auth: s.auth, logger: s.logger });
145
+ this.webPush = new WebPushModule({
146
+ remote: s.remote,
147
+ auth: s.auth,
148
+ persistence: s.persistence,
149
+ logger: s.logger,
150
+ database: config.database,
151
+ connection: s.connectionSupervisor,
152
+ config: config.webPush,
153
+ });
154
+ this.disposers.push(this.webPush.attach());
142
155
 
143
156
  const querySource: DevToolsQuerySource = {
144
157
  getActiveQueries: () => [...this.runtime.state.queries.values()].map(toQueryState),
package/src/index.ts CHANGED
@@ -15,6 +15,39 @@ export {
15
15
  type AppReleaseOptions,
16
16
  type AppReleaseSnapshot,
17
17
  } from './modules/app-release/index';
18
+ export {
19
+ WebPushModule,
20
+ WebPushError,
21
+ detectWebPushSupport,
22
+ PUSH_ENDPOINT_KEY,
23
+ type WebPushConfig,
24
+ type WebPushSupport,
25
+ type WebPushUnsupportedReason,
26
+ type WebPushInfo,
27
+ type WebPushDevice,
28
+ type PushDeviceKind,
29
+ type WebPushSubscribeOptions,
30
+ type WebPushUnsubscribeOptions,
31
+ type WebPushSyncOptions,
32
+ type WebPushSyncStatus,
33
+ type WebPushUpdateOptions,
34
+ type WebPushMessageInput,
35
+ type WebPushMessage,
36
+ type WebPushErrorCode,
37
+ } from './modules/web-push/index';
38
+ export {
39
+ PUSH_PAYLOAD_VERSION,
40
+ isPushPayload,
41
+ type PushPayload,
42
+ type PushPayloadKind,
43
+ type PushOp,
44
+ type PushUrgency,
45
+ type PushNotification,
46
+ type PushNotificationAction,
47
+ type BridgeTokenMessage,
48
+ type BridgeSignOutMessage,
49
+ type WebPushClientMessage,
50
+ } from './push/types';
18
51
  export type {
19
52
  BlobCacheStats,
20
53
  BlobEntry,