edges-svelte 3.0.1 → 3.1.0

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 CHANGED
@@ -13,6 +13,15 @@ No context boilerplate. No hydration headaches.
13
13
 
14
14
  EdgeS is built to prevent state leaks. Its primary goal is to keep server-side state safely isolated per request while providing a clean developer experience with presenters, stores, and automatic client updates when fresh state arrives from the server. It is intentionally one-way sync from server to client and does not aim to provide full two-way state synchronization between client and server.
15
15
 
16
+ ### Sync Scope & Limitations
17
+
18
+ EdgeS does **not** aim to provide full server-to-client synchronization for all SvelteKit flows.
19
+
20
+ - The main goal of this package is **server-side state isolation per request**.
21
+ - Server-to-client sync is a convenience layer for common cases, not a strict consistency protocol.
22
+ - `svelte actions` with redirects are **not fully synchronized** by design.
23
+ - Redirect-driven flows (and similar edge cases) should be handled with explicit app-level patterns (for example cookies/session/flash state) when you need guaranteed transfer of action results across navigation.
24
+
16
25
  > Designed for **SvelteKit**.
17
26
 
18
27
  ---
@@ -1,4 +1,4 @@
1
- import { browser } from '../utils/environment.js';
1
+ import { BROWSER } from '@azure-net/tools/environment';
2
2
  import { batch } from '../utils/batch.js';
3
3
  const stateUpdateCallbacks = new Map();
4
4
  const UNDEFINED_MARKER = '__EDGES_UNDEFINED__';
@@ -6,7 +6,8 @@ const NULL_MARKER = '__EDGES_NULL__';
6
6
  const BIGINT_MARKER = '__EDGES_BIGINT__';
7
7
  const EDGES_STATE_FIELD = '__edges_state__';
8
8
  const EDGES_REV_FIELD = '__edges_rev__';
9
- let lastAppliedRevision = 0;
9
+ const SEEN_REVISIONS_LIMIT = 200;
10
+ const seenRevisions = new Set();
10
11
  const decodeEdgesValue = (value) => {
11
12
  if (value && typeof value === 'object') {
12
13
  if (UNDEFINED_MARKER in value)
@@ -26,8 +27,23 @@ const decodeEdgesValue = (value) => {
26
27
  }
27
28
  return value;
28
29
  };
30
+ const tryDecodeLegacyString = (value) => {
31
+ const trimmed = value.trim();
32
+ if (!(trimmed.startsWith('{') || trimmed.startsWith('['))) {
33
+ return value;
34
+ }
35
+ if (!trimmed.includes(UNDEFINED_MARKER) && !trimmed.includes(NULL_MARKER) && !trimmed.includes(BIGINT_MARKER)) {
36
+ return value;
37
+ }
38
+ try {
39
+ return decodeEdgesValue(JSON.parse(trimmed));
40
+ }
41
+ catch {
42
+ return value;
43
+ }
44
+ };
29
45
  export function registerStateUpdate(key, callback) {
30
- if (browser) {
46
+ if (BROWSER) {
31
47
  stateUpdateCallbacks.set(key, callback);
32
48
  }
33
49
  }
@@ -39,15 +55,7 @@ export function processEdgesState(edgesState) {
39
55
  window.__SAFE_SSR_STATE__ = store;
40
56
  batch(() => {
41
57
  for (const [key, value] of Object.entries(edgesState)) {
42
- let processedValue = decodeEdgesValue(value);
43
- if (typeof value === 'string') {
44
- try {
45
- processedValue = decodeEdgesValue(JSON.parse(value));
46
- }
47
- catch {
48
- /* do nothing */
49
- }
50
- }
58
+ const processedValue = typeof value === 'string' ? tryDecodeLegacyString(value) : decodeEdgesValue(value);
51
59
  store.set(key, processedValue);
52
60
  const callback = stateUpdateCallbacks.get(key);
53
61
  if (callback) {
@@ -63,15 +71,21 @@ export function applyEdgesFromPayload(payload) {
63
71
  const rawState = data[EDGES_STATE_FIELD];
64
72
  if (!rawState || typeof rawState !== 'object')
65
73
  return;
66
- const revision = Number(data[EDGES_REV_FIELD] ?? 0);
67
- if (Number.isFinite(revision) && revision > 0) {
68
- if (revision <= lastAppliedRevision)
74
+ const revision = data[EDGES_REV_FIELD];
75
+ if (typeof revision === 'string' && revision.length > 0) {
76
+ if (seenRevisions.has(revision))
69
77
  return;
70
- lastAppliedRevision = revision;
78
+ seenRevisions.add(revision);
79
+ if (seenRevisions.size > SEEN_REVISIONS_LIMIT) {
80
+ const oldestRevision = seenRevisions.values().next().value;
81
+ if (oldestRevision) {
82
+ seenRevisions.delete(oldestRevision);
83
+ }
84
+ }
71
85
  }
72
86
  processEdgesState(rawState);
73
87
  }
74
- if (browser) {
88
+ if (BROWSER) {
75
89
  if (!window.__EDGES_NAVIGATION_SYNC_MOUNTED__) {
76
90
  window.__EDGES_NAVIGATION_SYNC_MOUNTED__ = true;
77
91
  void Promise.all([import('svelte'), import('./NavigationStateObserver.svelte')])
@@ -9,7 +9,7 @@ export interface ContextData {
9
9
  providersAutoKeyCounters?: Map<string, number>;
10
10
  providersConstructionStack?: string[];
11
11
  edgesDirtyKeys?: Set<string>;
12
- edgesRevision?: number;
12
+ edgesRevision?: string;
13
13
  } & App.ContextDataExtended;
14
14
  }
15
15
  declare class RequestContextManager {
@@ -1,11 +1,11 @@
1
- import { browser } from '../utils/environment.js';
1
+ import { BROWSER } from '@azure-net/tools/environment';
2
2
  class RequestContextManager {
3
3
  _currentGetter;
4
4
  init(getter) {
5
5
  this._currentGetter = getter;
6
6
  }
7
7
  current() {
8
- if (browser) {
8
+ if (BROWSER) {
9
9
  return { data: { message: 'Do not use request context on client side' } };
10
10
  }
11
11
  if (!this._currentGetter) {
@@ -1,8 +1,7 @@
1
1
  import { createState as BaseCreateState, createDerivedState as BaseCreateDerivedState, createRawState as BaseCreateRawState } from '../store/index.js';
2
+ import { BROWSER, DEV } from '@azure-net/tools/environment';
2
3
  import { RequestContext } from '../context/index.js';
3
- import { browser } from '../utils/environment.js';
4
- import { DevTools } from '../utils/dev.js';
5
- import { dev } from '../utils/environment.js';
4
+ import { recordProviderAccess, registerProviderDefinition, trackFactoryUniqueness, validateNamedProviderUniqueness } from './Utils.js';
6
5
  const globalClientCache = new Map();
7
6
  const globalConstructionStack = [];
8
7
  const PROVIDER_FACTORY_MARK = Symbol.for('edges-svelte.provider.factory');
@@ -17,7 +16,7 @@ class AutoKeyGenerator {
17
16
  cache = this.cache;
18
17
  counters = this.counters;
19
18
  };
20
- if (browser) {
19
+ if (BROWSER) {
21
20
  setGlobalCacheSystem();
22
21
  }
23
22
  else {
@@ -45,7 +44,7 @@ class AutoKeyGenerator {
45
44
  counters.set(baseKey, 0);
46
45
  }
47
46
  cache.set(factory, finalKey);
48
- DevTools.validateFactoryUniqueness(factory, finalKey);
47
+ trackFactoryUniqueness(factory, finalKey);
49
48
  return finalKey;
50
49
  }
51
50
  static hash(str) {
@@ -58,7 +57,7 @@ class AutoKeyGenerator {
58
57
  }
59
58
  }
60
59
  export const clearCache = (pattern) => {
61
- if (browser) {
60
+ if (BROWSER) {
62
61
  if (pattern) {
63
62
  for (const [key] of globalClientCache) {
64
63
  if (key.includes(pattern)) {
@@ -73,7 +72,7 @@ export const clearCache = (pattern) => {
73
72
  };
74
73
  const createUiProvider = (cacheKey, factory, dependencies, inject) => {
75
74
  const readConstructionStack = () => {
76
- if (browser)
75
+ if (BROWSER)
77
76
  return globalConstructionStack;
78
77
  try {
79
78
  const context = RequestContext.current();
@@ -89,7 +88,7 @@ const createUiProvider = (cacheKey, factory, dependencies, inject) => {
89
88
  return `[edges-svelte] Circular provider dependency detected while constructing "${key}". Chain: ${chain.join(' -> ')}.`;
90
89
  };
91
90
  const validateLazyInjection = (ownerKey, injections) => {
92
- if (!dev || !injections)
91
+ if (!DEV || !injections)
93
92
  return;
94
93
  for (const [depKey, depValue] of Object.entries(injections)) {
95
94
  if (depValue && typeof depValue === 'object') {
@@ -119,7 +118,7 @@ const createUiProvider = (cacheKey, factory, dependencies, inject) => {
119
118
  };
120
119
  const provider = (() => {
121
120
  let contextMap;
122
- if (browser) {
121
+ if (BROWSER) {
123
122
  contextMap = globalClientCache;
124
123
  }
125
124
  else {
@@ -137,6 +136,7 @@ const createUiProvider = (cacheKey, factory, dependencies, inject) => {
137
136
  if (contextMap.has(cacheKey)) {
138
137
  const cached = contextMap.get(cacheKey);
139
138
  if (cached !== undefined) {
139
+ recordProviderAccess(cacheKey, cached, BROWSER ? contextMap.size : undefined);
140
140
  return cached;
141
141
  }
142
142
  }
@@ -161,6 +161,7 @@ const createUiProvider = (cacheKey, factory, dependencies, inject) => {
161
161
  }
162
162
  markProviderInstance(instance);
163
163
  contextMap.set(cacheKey, instance);
164
+ recordProviderAccess(cacheKey, instance, BROWSER ? contextMap.size : undefined);
164
165
  return instance;
165
166
  });
166
167
  try {
@@ -181,7 +182,11 @@ export function createStore(nameOrFactory, factoryOrInject, inject) {
181
182
  const name = isNameProvided ? nameOrFactory : undefined;
182
183
  const factory = isNameProvided ? factoryOrInject : nameOrFactory;
183
184
  const injections = isNameProvided ? inject : factoryOrInject;
185
+ if (isNameProvided) {
186
+ validateNamedProviderUniqueness(name, 'store', factory);
187
+ }
184
188
  const cacheKey = name || AutoKeyGenerator.generate(factory);
189
+ registerProviderDefinition(cacheKey, 'store', factory, Boolean(name));
185
190
  return createUiProvider(cacheKey, factory, (key) => {
186
191
  let stateCounter = 0;
187
192
  return {
@@ -215,7 +220,11 @@ export function createPresenter(nameOrFactory, factoryOrInject, inject) {
215
220
  const name = isNameProvided ? nameOrFactory : undefined;
216
221
  const factory = isNameProvided ? factoryOrInject : nameOrFactory;
217
222
  const injections = isNameProvided ? inject : factoryOrInject;
223
+ if (isNameProvided) {
224
+ validateNamedProviderUniqueness(name, 'presenter', factory);
225
+ }
218
226
  const cacheKey = name || AutoKeyGenerator.generate(factory);
227
+ registerProviderDefinition(cacheKey, 'presenter', factory, Boolean(name));
219
228
  return createUiProvider(cacheKey, factory, {}, injections);
220
229
  }
221
230
  export const createPresenterFactory = (inject) => {
@@ -0,0 +1,34 @@
1
+ import type { UnknownFunc } from '../types.js';
2
+ export type ProviderKind = 'store' | 'presenter';
3
+ export interface ProviderDevDefinitionSnapshot {
4
+ key: string;
5
+ kind: ProviderKind;
6
+ factoryName: string;
7
+ named: boolean;
8
+ registeredAt: number;
9
+ }
10
+ export interface ProviderDevRuntimeSnapshot {
11
+ key: string;
12
+ instantiated: boolean;
13
+ hits: number;
14
+ lastAccessedAt: number | null;
15
+ instanceSizeBytes: number;
16
+ }
17
+ export interface ProviderDevSnapshot {
18
+ definitions: ProviderDevDefinitionSnapshot[];
19
+ runtimes: ProviderDevRuntimeSnapshot[];
20
+ providerCacheEntries: number;
21
+ providerCacheSizeBytes: number;
22
+ }
23
+ export interface ProviderDuplicateAttempt {
24
+ key: string;
25
+ attemptedKind: ProviderKind;
26
+ existingKind: ProviderKind;
27
+ at: number;
28
+ }
29
+ export declare const trackFactoryUniqueness: (factory: UnknownFunc, key: string) => void;
30
+ export declare const validateNamedProviderUniqueness: (key: string, kind: ProviderKind, factory: UnknownFunc) => void;
31
+ export declare const registerProviderDefinition: (key: string, kind: ProviderKind, factory: UnknownFunc, named: boolean) => void;
32
+ export declare const recordProviderAccess: (key: string, instance: unknown, cacheEntriesHint?: number) => void;
33
+ export declare const getProviderDevSnapshot: () => ProviderDevSnapshot;
34
+ export declare const getProviderDuplicateAttempts: () => ProviderDuplicateAttempt[];
@@ -0,0 +1,102 @@
1
+ import { BROWSER, DEV } from '@azure-net/tools/environment';
2
+ const seenFactories = new WeakSet();
3
+ const namedProviderRegistry = new Map();
4
+ const duplicateNamedProviderEvents = [];
5
+ const providerDefinitions = new Map();
6
+ const providerRuntime = new Map();
7
+ let providerCacheEntries = 0;
8
+ let providerCacheSizeBytes = 0;
9
+ const safeJsonLength = (value) => {
10
+ try {
11
+ const serialized = JSON.stringify(value);
12
+ return serialized ? serialized.length : 0;
13
+ }
14
+ catch {
15
+ return 0;
16
+ }
17
+ };
18
+ const estimateSize = (value) => {
19
+ if (typeof value === 'bigint')
20
+ return value.toString().length;
21
+ if (typeof value === 'function' || typeof value === 'symbol')
22
+ return 0;
23
+ if (typeof value === 'string')
24
+ return value.length;
25
+ if (value === undefined || value === null)
26
+ return 0;
27
+ return safeJsonLength(value);
28
+ };
29
+ export const trackFactoryUniqueness = (factory, key) => {
30
+ if (!DEV)
31
+ return;
32
+ if (seenFactories.has(factory)) {
33
+ console.warn(`[edges-svelte] Factory collision detected for key "${key}". ` +
34
+ `This might cause unexpected behavior.` +
35
+ `Set a unique __storeKey__ property on your factory function.`);
36
+ }
37
+ seenFactories.add(factory);
38
+ };
39
+ export const validateNamedProviderUniqueness = (key, kind, factory) => {
40
+ if (!DEV)
41
+ return;
42
+ const existing = namedProviderRegistry.get(key);
43
+ if (!existing) {
44
+ namedProviderRegistry.set(key, { kind, factory });
45
+ return;
46
+ }
47
+ if (existing.factory === factory && existing.kind === kind) {
48
+ return;
49
+ }
50
+ duplicateNamedProviderEvents.push({
51
+ key,
52
+ attemptedKind: kind,
53
+ existingKind: existing.kind,
54
+ at: Date.now()
55
+ });
56
+ throw new Error(`[edges-svelte] Duplicate ${kind} key "${key}" detected. ` +
57
+ `This key is already used by a ${existing.kind}. Use unique names for createStore/createPresenter.`);
58
+ };
59
+ export const registerProviderDefinition = (key, kind, factory, named) => {
60
+ if (!DEV || !BROWSER)
61
+ return;
62
+ if (providerDefinitions.has(key))
63
+ return;
64
+ providerDefinitions.set(key, {
65
+ key,
66
+ kind,
67
+ factoryName: factory.displayName || factory.name || '(anonymous)',
68
+ named,
69
+ registeredAt: Date.now()
70
+ });
71
+ };
72
+ export const recordProviderAccess = (key, instance, cacheEntriesHint) => {
73
+ if (!DEV || !BROWSER)
74
+ return;
75
+ const runtime = providerRuntime.get(key) ?? {
76
+ key,
77
+ instantiated: false,
78
+ hits: 0,
79
+ lastAccessedAt: null,
80
+ instanceSizeBytes: 0
81
+ };
82
+ runtime.instantiated = true;
83
+ runtime.hits += 1;
84
+ runtime.lastAccessedAt = Date.now();
85
+ runtime.instanceSizeBytes = estimateSize(instance);
86
+ providerRuntime.set(key, runtime);
87
+ if (typeof cacheEntriesHint === 'number') {
88
+ providerCacheEntries = cacheEntriesHint;
89
+ }
90
+ providerCacheSizeBytes = 0;
91
+ for (const [providerKey, tracked] of providerRuntime) {
92
+ providerCacheSizeBytes += providerKey.length;
93
+ providerCacheSizeBytes += tracked.instanceSizeBytes;
94
+ }
95
+ };
96
+ export const getProviderDevSnapshot = () => ({
97
+ definitions: Array.from(providerDefinitions.values()),
98
+ runtimes: Array.from(providerRuntime.values()),
99
+ providerCacheEntries,
100
+ providerCacheSizeBytes
101
+ });
102
+ export const getProviderDuplicateAttempts = () => [...duplicateNamedProviderEvents];
@@ -1,14 +1,14 @@
1
1
  import { stateSerialize } from '../store/State.svelte.js';
2
2
  import { AsyncLocalStorage } from 'node:async_hooks';
3
+ import { randomUUID } from 'node:crypto';
3
4
  import { RequestContext } from '../context/Context.js';
4
5
  const storage = new AsyncLocalStorage();
5
- let requestRevision = 0;
6
6
  export const edgesHandle = async (event, callback, silentChromeDevtools = false) => {
7
7
  const requestSymbol = Symbol('request');
8
8
  return await storage.run({
9
9
  event: event,
10
10
  symbol: requestSymbol,
11
- data: { providers: new Map(), edgesDirtyKeys: new Set(), edgesRevision: ++requestRevision }
11
+ data: { providers: new Map(), edgesDirtyKeys: new Set(), edgesRevision: randomUUID() }
12
12
  }, async () => {
13
13
  RequestContext.init(() => {
14
14
  const context = storage.getStore();
@@ -33,6 +33,8 @@ export const edgesHandle = async (event, callback, silentChromeDevtools = false)
33
33
  serialize: (html) => {
34
34
  if (!html)
35
35
  return html ?? '';
36
+ if (!html.includes('</body>'))
37
+ return html;
36
38
  const serialized = stateSerialize();
37
39
  if (!serialized)
38
40
  return html;
@@ -1,6 +1,6 @@
1
1
  import { getStateMap } from '../store/State.svelte.js';
2
2
  import { RequestContext } from '../context/Context.js';
3
- import { build, dev } from '../utils/environment.js';
3
+ import { DEV } from '@azure-net/tools/environment';
4
4
  const UNDEFINED_MARKER = '__EDGES_UNDEFINED__';
5
5
  const NULL_MARKER = '__EDGES_NULL__';
6
6
  const BIGINT_MARKER = '__EDGES_BIGINT__';
@@ -9,7 +9,6 @@ const EDGES_REV_FIELD = '__edges_rev__';
9
9
  const isObjectRecord = (value) => {
10
10
  return typeof value === 'object' && value !== null && !Array.isArray(value);
11
11
  };
12
- const PROFILE_EDGES_DELTA = dev && !build;
13
12
  const encodeEdgesValue = (value) => {
14
13
  if (value === undefined)
15
14
  return { [UNDEFINED_MARKER]: true };
@@ -30,11 +29,11 @@ const encodeEdgesValue = (value) => {
30
29
  return value;
31
30
  };
32
31
  const getEdgesDelta = () => {
33
- const startedAt = PROFILE_EDGES_DELTA ? performance.now() : 0;
32
+ const startedAt = DEV ? performance.now() : 0;
34
33
  try {
35
34
  const context = RequestContext.current();
36
35
  const dirtyKeys = context.data.edgesDirtyKeys;
37
- const rev = context.data.edgesRevision ?? 0;
36
+ const rev = context.data.edgesRevision;
38
37
  const stateMap = getStateMap();
39
38
  if (!dirtyKeys || dirtyKeys.size === 0 || !stateMap || stateMap.size === 0)
40
39
  return undefined;
@@ -44,7 +43,7 @@ const getEdgesDelta = () => {
44
43
  continue;
45
44
  state[key] = encodeEdgesValue(stateMap.get(key));
46
45
  }
47
- if (Object.keys(state).length === 0)
46
+ if (!rev || Object.keys(state).length === 0)
48
47
  return undefined;
49
48
  return { state, rev };
50
49
  }
@@ -52,7 +51,7 @@ const getEdgesDelta = () => {
52
51
  return undefined;
53
52
  }
54
53
  finally {
55
- if (PROFILE_EDGES_DELTA) {
54
+ if (DEV) {
56
55
  const duration = performance.now() - startedAt;
57
56
  if (duration > 4) {
58
57
  console.debug(`[edges-svelte] edges delta encode took ${duration.toFixed(2)}ms`);
@@ -2,7 +2,7 @@ import { type Readable, type Writable } from 'svelte/store';
2
2
  declare global {
3
3
  interface Window {
4
4
  __SAFE_SSR_STATE__?: Map<string, unknown>;
5
- __EDGES_DEVTOOLS__: Record<string, unknown>;
5
+ __EDGES_DEVTOOLS__?: Record<string, unknown>;
6
6
  }
7
7
  }
8
8
  export declare const stateSerialize: () => string;
@@ -1,5 +1,5 @@
1
1
  import { RequestContext } from '../context/Context.js';
2
- import { browser } from '../utils/environment.js';
2
+ import { BROWSER } from '@azure-net/tools/environment';
3
3
  import { derived, writable } from 'svelte/store';
4
4
  import { registerStateUpdate } from '../client/NavigationSync.svelte.js';
5
5
  import { queueUpdate, isBatching } from '../utils/batch.js';
@@ -56,7 +56,7 @@ const getRequestContext = () => {
56
56
  }
57
57
  };
58
58
  const markStateDirty = (key) => {
59
- if (browser)
59
+ if (BROWSER)
60
60
  return;
61
61
  try {
62
62
  const context = RequestContext.current();
@@ -85,7 +85,7 @@ const getBrowserState = (key, initial) => {
85
85
  return initial;
86
86
  };
87
87
  export const createRawState = (key, initial) => {
88
- if (browser) {
88
+ if (BROWSER) {
89
89
  let state = $state(getBrowserState(key, initial()));
90
90
  const updateWindowState = (val) => {
91
91
  if (!window.__SAFE_SSR_STATE__) {
@@ -138,7 +138,7 @@ export const createRawState = (key, initial) => {
138
138
  };
139
139
  };
140
140
  export const createState = (key, initial) => {
141
- if (browser) {
141
+ if (BROWSER) {
142
142
  const initialValue = getBrowserState(key, initial());
143
143
  const state = writable(initialValue);
144
144
  let currentValue = initialValue;
@@ -222,7 +222,7 @@ export const createState = (key, initial) => {
222
222
  };
223
223
  };
224
224
  export const createDerivedState = (stores, deriveFn) => {
225
- if (browser) {
225
+ if (BROWSER) {
226
226
  return derived(stores, deriveFn);
227
227
  }
228
228
  return {