@concavejs/runtime-cf-base 0.0.1-alpha.8 → 0.0.1-alpha.9
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/dist/http/http-api.d.ts +32 -1
- package/dist/http/http-api.js +95 -15
- package/dist/index.d.ts +2 -1
- package/dist/index.js +1 -0
- package/dist/routing/sync-topology.d.ts +40 -0
- package/dist/routing/sync-topology.js +669 -0
- package/dist/worker/create-concave-worker.d.ts +45 -0
- package/dist/worker/create-concave-worker.js +36 -5
- package/package.json +5 -1
package/dist/http/http-api.d.ts
CHANGED
|
@@ -1,7 +1,38 @@
|
|
|
1
|
+
import type { SerializedKeyRange } from "@concavejs/core/queryengine";
|
|
1
2
|
/** Minimal Env shape needed by the HTTP API handler */
|
|
2
3
|
interface Env {
|
|
3
4
|
CONCAVE_DO: DurableObjectNamespace;
|
|
4
5
|
SYNC_DO: DurableObjectNamespace;
|
|
6
|
+
SYNC_NOTIFY_QUEUE?: QueueLike;
|
|
5
7
|
}
|
|
6
|
-
|
|
8
|
+
type QueueLike = {
|
|
9
|
+
send(message: unknown): Promise<void>;
|
|
10
|
+
};
|
|
11
|
+
export interface HttpApiRoutingTargets {
|
|
12
|
+
/** Physical ConcaveDO name for execution traffic. Defaults to the logical `instance`. */
|
|
13
|
+
concaveDoName?: string;
|
|
14
|
+
/** Alias for non-DO runtimes to describe the write execution target identifier. */
|
|
15
|
+
concaveTargetName?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Physical SyncDO names to notify after writes.
|
|
18
|
+
* Defaults to `[instance]`.
|
|
19
|
+
*/
|
|
20
|
+
syncDoNames?: string[];
|
|
21
|
+
/** Alias for non-DO runtimes to describe sync invalidation targets. */
|
|
22
|
+
syncTargetNames?: string[];
|
|
23
|
+
/**
|
|
24
|
+
* Optional custom write dispatcher.
|
|
25
|
+
* When set, it is responsible for fanning out invalidations (queue, stream, etc.).
|
|
26
|
+
*/
|
|
27
|
+
dispatchSyncWrites?: (payload: SyncWriteDispatchPayload) => Promise<void>;
|
|
28
|
+
}
|
|
29
|
+
export interface SyncWriteDispatchPayload {
|
|
30
|
+
logicalInstance: string;
|
|
31
|
+
projectId?: string;
|
|
32
|
+
syncTargets: string[];
|
|
33
|
+
writtenRanges?: SerializedKeyRange[];
|
|
34
|
+
writtenTables?: string[];
|
|
35
|
+
commitTimestamp?: string;
|
|
36
|
+
}
|
|
37
|
+
export declare function handleHttpApiRequest(request: Request, env: Env, ctx: ExecutionContext, instance?: string, routingTargets?: HttpApiRoutingTargets): Promise<Response>;
|
|
7
38
|
export {};
|
package/dist/http/http-api.js
CHANGED
|
@@ -60,7 +60,7 @@ function base64ToArrayBuffer(base64) {
|
|
|
60
60
|
* Create a storage adapter that routes through the ConcaveDO's storage syscall handler.
|
|
61
61
|
* This ensures storage operations are properly isolated within the DO.
|
|
62
62
|
*/
|
|
63
|
-
function createStorageAdapter(concaveDO
|
|
63
|
+
function createStorageAdapter(concaveDO) {
|
|
64
64
|
return {
|
|
65
65
|
store: async (blob) => {
|
|
66
66
|
const buffer = await blob.arrayBuffer();
|
|
@@ -106,26 +106,66 @@ function createStorageAdapter(concaveDO, _instance) {
|
|
|
106
106
|
},
|
|
107
107
|
};
|
|
108
108
|
}
|
|
109
|
-
function createNotifyWrites(env,
|
|
109
|
+
function createNotifyWrites(env, syncDoNames, ctx, logicalInstance, projectId, dispatchSyncWrites) {
|
|
110
110
|
return async (writtenRanges, writtenTables, commitTimestamp) => {
|
|
111
111
|
if (!writtenRanges?.length && !writtenTables?.length) {
|
|
112
112
|
return;
|
|
113
113
|
}
|
|
114
|
-
const syncDoId = env.SYNC_DO.idFromName(instance);
|
|
115
|
-
const syncDo = env.SYNC_DO.get(syncDoId);
|
|
116
114
|
const payload = {
|
|
115
|
+
logicalInstance,
|
|
116
|
+
projectId,
|
|
117
|
+
syncTargets: syncDoNames,
|
|
117
118
|
writtenRanges,
|
|
118
119
|
writtenTables: writtenTables ?? writtenTablesFromRanges(writtenRanges),
|
|
119
120
|
commitTimestamp: commitTimestamp ? commitTimestamp.toString() : undefined,
|
|
120
121
|
};
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
122
|
+
if (dispatchSyncWrites) {
|
|
123
|
+
ctx.waitUntil(dispatchSyncWrites(payload).catch((error) => {
|
|
124
|
+
console.warn("[notifyWrites] Custom sync write dispatch failed", error);
|
|
125
|
+
}));
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
if (isQueueLike(env.SYNC_NOTIFY_QUEUE)) {
|
|
129
|
+
ctx.waitUntil(env.SYNC_NOTIFY_QUEUE.send(payload).catch((error) => {
|
|
130
|
+
console.warn("[notifyWrites] Queue-based sync write dispatch failed", error);
|
|
131
|
+
}));
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const doPayload = {
|
|
135
|
+
writtenRanges,
|
|
136
|
+
writtenTables: writtenTables ?? writtenTablesFromRanges(writtenRanges),
|
|
137
|
+
commitTimestamp: commitTimestamp ? commitTimestamp.toString() : undefined,
|
|
138
|
+
};
|
|
139
|
+
for (const syncDoName of syncDoNames) {
|
|
140
|
+
const syncDoId = env.SYNC_DO.idFromName(syncDoName);
|
|
141
|
+
const syncDo = env.SYNC_DO.get(syncDoId);
|
|
142
|
+
const headers = {
|
|
143
|
+
"Content-Type": "application/json",
|
|
144
|
+
"X-Concave-Instance": logicalInstance,
|
|
145
|
+
};
|
|
146
|
+
if (projectId) {
|
|
147
|
+
headers["X-Concave-Project-Id"] = projectId;
|
|
148
|
+
}
|
|
149
|
+
ctx.waitUntil(syncDo
|
|
150
|
+
.fetch("http://do/notify", {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers,
|
|
153
|
+
body: JSON.stringify(doPayload),
|
|
154
|
+
})
|
|
155
|
+
.catch((error) => {
|
|
156
|
+
console.warn("[notifyWrites] Direct SyncDO notify failed", error);
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
126
159
|
};
|
|
127
160
|
}
|
|
128
|
-
|
|
161
|
+
function isQueueLike(value) {
|
|
162
|
+
if (!value || typeof value !== "object") {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
const candidate = value;
|
|
166
|
+
return typeof candidate.send === "function";
|
|
167
|
+
}
|
|
168
|
+
export async function handleHttpApiRequest(request, env, ctx, instance = "singleton", routingTargets) {
|
|
129
169
|
const corsHeaders = computeCorsHeaders(request);
|
|
130
170
|
const apply = (response) => applyCors(response, corsHeaders);
|
|
131
171
|
const url = new URL(request.url);
|
|
@@ -133,14 +173,19 @@ export async function handleHttpApiRequest(request, env, ctx, instance = "single
|
|
|
133
173
|
if (pathParts[0] !== "api") {
|
|
134
174
|
return apply(new Response("Not found", { status: 404 }));
|
|
135
175
|
}
|
|
136
|
-
|
|
137
|
-
const
|
|
138
|
-
const
|
|
176
|
+
const logicalInstance = instance;
|
|
177
|
+
const concaveDoName = normalizeDoName(routingTargets?.concaveDoName ?? routingTargets?.concaveTargetName, logicalInstance);
|
|
178
|
+
const syncDoNames = normalizeSyncDoNames(routingTargets?.syncDoNames ?? routingTargets?.syncTargetNames, logicalInstance);
|
|
179
|
+
const projectId = request.headers.get("X-Concave-Project-Id") ?? undefined;
|
|
180
|
+
console.log(`[handleHttpApiRequest] logicalInstance=${logicalInstance} concaveDo=${concaveDoName} syncTargets=${syncDoNames.join(",")}`);
|
|
181
|
+
const concaveId = env.CONCAVE_DO.idFromName(concaveDoName);
|
|
182
|
+
const concaveRaw = env.CONCAVE_DO.get(concaveId);
|
|
183
|
+
const concave = wrapConcaveStub(concaveRaw, logicalInstance, projectId);
|
|
139
184
|
const executor = new ConcaveStubExecutor(concave);
|
|
140
185
|
const adapter = createClientAdapter(executor);
|
|
141
|
-
const notifyWrites = createNotifyWrites(env,
|
|
186
|
+
const notifyWrites = createNotifyWrites(env, syncDoNames, ctx, logicalInstance, projectId, routingTargets?.dispatchSyncWrites);
|
|
142
187
|
// Route storage operations through the DO's storage syscall handler
|
|
143
|
-
const storageAdapter = createStorageAdapter(concave
|
|
188
|
+
const storageAdapter = createStorageAdapter(concave);
|
|
144
189
|
const authHeader = request.headers.get("Authorization");
|
|
145
190
|
const headerToken = authHeader?.replace(/^Bearer\s+/i, "").trim() || undefined;
|
|
146
191
|
let headerIdentity;
|
|
@@ -294,3 +339,38 @@ export async function handleHttpApiRequest(request, env, ctx, instance = "single
|
|
|
294
339
|
}
|
|
295
340
|
return apply(new Response("Not found", { status: 404 }));
|
|
296
341
|
}
|
|
342
|
+
function wrapConcaveStub(stub, logicalInstance, projectId) {
|
|
343
|
+
return {
|
|
344
|
+
fetch: async (input, init) => {
|
|
345
|
+
const headers = new Headers(init?.headers);
|
|
346
|
+
headers.set("X-Concave-Instance", logicalInstance);
|
|
347
|
+
if (projectId) {
|
|
348
|
+
headers.set("X-Concave-Project-Id", projectId);
|
|
349
|
+
}
|
|
350
|
+
return stub.fetch(input, {
|
|
351
|
+
...init,
|
|
352
|
+
headers,
|
|
353
|
+
});
|
|
354
|
+
},
|
|
355
|
+
};
|
|
356
|
+
}
|
|
357
|
+
function normalizeDoName(target, fallback) {
|
|
358
|
+
const trimmed = target?.trim();
|
|
359
|
+
return trimmed && trimmed.length > 0 ? trimmed : fallback;
|
|
360
|
+
}
|
|
361
|
+
function normalizeSyncDoNames(targets, fallback) {
|
|
362
|
+
if (targets === undefined) {
|
|
363
|
+
return [fallback];
|
|
364
|
+
}
|
|
365
|
+
if (targets.length === 0) {
|
|
366
|
+
return [];
|
|
367
|
+
}
|
|
368
|
+
const deduped = new Set();
|
|
369
|
+
for (const target of targets) {
|
|
370
|
+
const trimmed = target.trim();
|
|
371
|
+
if (trimmed.length > 0) {
|
|
372
|
+
deduped.add(trimmed);
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
return Array.from(deduped);
|
|
376
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
export * from "@concavejs/core";
|
|
2
2
|
export type { ConcaveEnv, ConcaveStorageEnv, ConcaveD1Env } from "./env";
|
|
3
3
|
export { UdfExecutorRpc } from "./worker/udf-worker";
|
|
4
|
-
export { createConcaveWorker, resolveNamespaceBinding, createScopedNamespace, type ConcaveWorkerOptions, type ConcaveWorkerBindings, type ConcaveWorker, } from "./worker/create-concave-worker";
|
|
4
|
+
export { createConcaveWorker, resolveNamespaceBinding, createScopedNamespace, type ConcaveWorkerOptions, type ConcaveWorkerBindings, type ConcaveWorker, type ConcaveWorkerRouteContext, type ConcaveWorkerRouteTargets, } from "./worker/create-concave-worker";
|
|
5
5
|
export { DEFAULT_INSTANCE_KEY, DEFAULT_INSTANCE_VALUE, DEFAULT_INSTANCE_COOKIE_PATH, resolveInstanceFromRequest, maybeAttachInstanceCookie, readCookieValue, buildInstanceCookie, type InstanceResolution, type InstanceResolutionOptions, type InstanceCookieOptions, } from "./routing/instance";
|
|
6
|
+
export { createCfSyncTopologyRuntime, buildPooledInstanceName as buildSyncPooledInstanceName, parseNodeIds as parseSyncNodeIds, parseNodeIdsByRegion as parseSyncNodeIdsByRegion, normalizeTargetList as normalizeSyncTargetList, type CfSyncTopologyConfig, type BuildShardMapFromReportsOptions as SyncBuildShardMapFromReportsOptions, type ResolveSyncFanoutDoNamesAsyncOptions, type CreateCfSyncTopologyRuntimeOptions, type CfSyncTopologyRuntime, } from "./routing/sync-topology";
|
|
6
7
|
export { UdfExecInline } from "./udf/executor/inline-executor";
|
|
7
8
|
export { UdfExecIsolated } from "./udf/executor/isolated-executor";
|
|
8
9
|
export { ConcaveDOBase, type ConcaveDOConfig, type ConcaveDOAdapterContext, type ConcaveDOExecutorContext, } from "./durable-objects/concave-do-base";
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { UdfExecutorRpc } from "./worker/udf-worker";
|
|
|
5
5
|
export { createConcaveWorker, resolveNamespaceBinding, createScopedNamespace, } from "./worker/create-concave-worker";
|
|
6
6
|
// Export instance routing helpers
|
|
7
7
|
export { DEFAULT_INSTANCE_KEY, DEFAULT_INSTANCE_VALUE, DEFAULT_INSTANCE_COOKIE_PATH, resolveInstanceFromRequest, maybeAttachInstanceCookie, readCookieValue, buildInstanceCookie, } from "./routing/instance";
|
|
8
|
+
export { createCfSyncTopologyRuntime, buildPooledInstanceName as buildSyncPooledInstanceName, parseNodeIds as parseSyncNodeIds, parseNodeIdsByRegion as parseSyncNodeIdsByRegion, normalizeTargetList as normalizeSyncTargetList, } from "./routing/sync-topology";
|
|
8
9
|
// Export UDF executors
|
|
9
10
|
export { UdfExecInline } from "./udf/executor/inline-executor";
|
|
10
11
|
export { UdfExecIsolated } from "./udf/executor/isolated-executor";
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { ConcaveWorkerRouteContext, ConcaveWorkerRouteTargets } from "../worker/create-concave-worker";
|
|
2
|
+
import { type BuildShardMapFromReportsOptions as SharedBuildShardMapFromReportsOptions, type SyncShardMap, type SyncTopologyConfig } from "@concavejs/runtime-base";
|
|
3
|
+
export interface CfSyncTopologyConfig extends SyncTopologyConfig {
|
|
4
|
+
syncAffinityKey: string;
|
|
5
|
+
regionKey: string;
|
|
6
|
+
regionByColo: Record<string, string>;
|
|
7
|
+
useColoAsRegion: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface BuildShardMapFromReportsOptions extends Omit<SharedBuildShardMapFromReportsOptions, "config"> {
|
|
10
|
+
logicalInstance: string;
|
|
11
|
+
env: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export interface ResolveSyncFanoutDoNamesAsyncOptions {
|
|
14
|
+
request?: Request;
|
|
15
|
+
projectId?: string;
|
|
16
|
+
}
|
|
17
|
+
export interface CreateCfSyncTopologyRuntimeOptions {
|
|
18
|
+
logPrefix?: string;
|
|
19
|
+
projectHeaderName?: string;
|
|
20
|
+
coordinatorBindingName?: string;
|
|
21
|
+
shardMapKvBindingName?: string;
|
|
22
|
+
resolveProjectId?: (request: Request, env: Record<string, unknown>) => string | undefined;
|
|
23
|
+
buildCoordinatorDoName: (logicalInstance: string, projectId?: string) => string;
|
|
24
|
+
buildShardMapKvKey: (logicalInstance: string, projectId?: string) => string;
|
|
25
|
+
buildShardMapCacheKey?: (logicalInstance: string, projectId?: string) => string;
|
|
26
|
+
}
|
|
27
|
+
export interface CfSyncTopologyRuntime {
|
|
28
|
+
resolveRouteTargets(context: ConcaveWorkerRouteContext): Promise<ConcaveWorkerRouteTargets>;
|
|
29
|
+
resolveSyncFanoutDoNames(logicalInstance: string, env: Record<string, unknown>): string[];
|
|
30
|
+
resolveSyncFanoutDoNamesAsync(logicalInstance: string, env: Record<string, unknown>, options?: ResolveSyncFanoutDoNamesAsyncOptions): Promise<string[]>;
|
|
31
|
+
resolveStaticSyncShardMap(logicalInstance: string, env: Record<string, unknown>, nowMs?: number): SyncShardMap;
|
|
32
|
+
buildShardMapFromReports(options: BuildShardMapFromReportsOptions): SyncShardMap;
|
|
33
|
+
resolveTopologyConfig(env: Record<string, unknown>): CfSyncTopologyConfig;
|
|
34
|
+
resolveRequestRegion(request: Request, env: Record<string, unknown>): string;
|
|
35
|
+
}
|
|
36
|
+
export declare function createCfSyncTopologyRuntime(options: CreateCfSyncTopologyRuntimeOptions): CfSyncTopologyRuntime;
|
|
37
|
+
export declare function buildPooledInstanceName(logicalInstance: string, poolKind: "sync" | "udf", nodeId: string): string;
|
|
38
|
+
export declare function parseNodeIds(raw: unknown): string[];
|
|
39
|
+
export declare function parseNodeIdsByRegion(raw: unknown): Record<string, string[]>;
|
|
40
|
+
export declare function normalizeTargetList(targets: string[] | undefined): string[];
|
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
import { buildPooledInstanceName as buildSharedPooledInstanceName, buildShardMapFromReports as buildSharedShardMapFromReports, isValidSyncShardMap, normalizeTargetList as normalizeSharedTargetList, parseNodeIds as parseSharedNodeIds, parseNodeIdsByRegion as parseSharedNodeIdsByRegion, resolveBootstrapSyncShardCandidates, resolveStaticSyncShardMap as resolveSharedStaticShardMap, } from "@concavejs/runtime-base";
|
|
2
|
+
const DEFAULT_SYNC_NODE_IDS_ENV = "CONCAVE_SYNC_NODE_IDS";
|
|
3
|
+
const DEFAULT_SYNC_NODE_IDS_BY_REGION_ENV = "CONCAVE_SYNC_NODE_IDS_BY_REGION";
|
|
4
|
+
const DEFAULT_SYNC_AFFINITY_KEY_ENV = "CONCAVE_SYNC_AFFINITY_KEY";
|
|
5
|
+
const DEFAULT_SYNC_REGION_KEY_ENV = "CONCAVE_SYNC_REGION_KEY";
|
|
6
|
+
const DEFAULT_SYNC_DEFAULT_REGION_ENV = "CONCAVE_SYNC_DEFAULT_REGION";
|
|
7
|
+
const DEFAULT_SYNC_REGION_MAP_ENV = "CONCAVE_SYNC_REGION_MAP";
|
|
8
|
+
const DEFAULT_SYNC_USE_COLO_AS_REGION_ENV = "CONCAVE_SYNC_USE_COLO_AS_REGION";
|
|
9
|
+
const DEFAULT_SYNC_SHARD_MAP_CACHE_MS_ENV = "CONCAVE_SYNC_SHARD_MAP_CACHE_MS";
|
|
10
|
+
const DEFAULT_SYNC_NODE_STALE_MS_ENV = "CONCAVE_SYNC_NODE_STALE_MS";
|
|
11
|
+
const DEFAULT_SYNC_MIN_SHARDS_ENV = "CONCAVE_SYNC_MIN_SHARDS_PER_REGION";
|
|
12
|
+
const DEFAULT_SYNC_MAX_SHARDS_ENV = "CONCAVE_SYNC_MAX_SHARDS_PER_REGION";
|
|
13
|
+
const DEFAULT_SYNC_TARGET_SESSIONS_ENV = "CONCAVE_SYNC_TARGET_SESSIONS_PER_SHARD";
|
|
14
|
+
const DEFAULT_SYNC_TARGET_MESSAGE_RATE_ENV = "CONCAVE_SYNC_TARGET_MESSAGE_RATE_PER_SHARD";
|
|
15
|
+
const DEFAULT_SYNC_TARGET_NOTIFY_RATE_ENV = "CONCAVE_SYNC_TARGET_NOTIFY_RATE_PER_SHARD";
|
|
16
|
+
const DEFAULT_SYNC_TARGET_CPU_UTILIZATION_ENV = "CONCAVE_SYNC_TARGET_CPU_UTILIZATION";
|
|
17
|
+
const DEFAULT_SYNC_TARGET_MEMORY_UTILIZATION_ENV = "CONCAVE_SYNC_TARGET_MEMORY_UTILIZATION";
|
|
18
|
+
const DEFAULT_SYNC_AUTO_SHARDS_ENV = "CONCAVE_SYNC_AUTO_SHARDS_PER_REGION";
|
|
19
|
+
const DEFAULT_SYNC_AUTO_SHARD_PREFIX_ENV = "CONCAVE_SYNC_AUTO_SHARD_PREFIX";
|
|
20
|
+
const DEFAULT_SYNC_SCALE_TO_ZERO_ENV = "CONCAVE_SYNC_SCALE_TO_ZERO";
|
|
21
|
+
const DEFAULT_SYNC_AUTOSCALE_PROFILE_ENV = "CONCAVE_SYNC_AUTOSCALE_PROFILE";
|
|
22
|
+
const DEFAULT_SYNC_SCALE_UP_COOLDOWN_MS_ENV = "CONCAVE_SYNC_SCALE_UP_COOLDOWN_MS";
|
|
23
|
+
const DEFAULT_SYNC_SCALE_DOWN_COOLDOWN_MS_ENV = "CONCAVE_SYNC_SCALE_DOWN_COOLDOWN_MS";
|
|
24
|
+
const DEFAULT_SYNC_SCALE_UP_HYSTERESIS_RATIO_ENV = "CONCAVE_SYNC_SCALE_UP_HYSTERESIS_RATIO";
|
|
25
|
+
const DEFAULT_SYNC_SCALE_DOWN_HYSTERESIS_RATIO_ENV = "CONCAVE_SYNC_SCALE_DOWN_HYSTERESIS_RATIO";
|
|
26
|
+
const DEFAULT_SYNC_MAX_SCALE_UP_STEP_ENV = "CONCAVE_SYNC_MAX_SCALE_UP_STEP";
|
|
27
|
+
const DEFAULT_SYNC_MAX_SCALE_DOWN_STEP_ENV = "CONCAVE_SYNC_MAX_SCALE_DOWN_STEP";
|
|
28
|
+
const DEFAULT_PROJECT_HEADER = "X-Concave-Project-Id";
|
|
29
|
+
const DEFAULT_SYNC_COORDINATOR_BINDING = "SYNC_COORDINATOR_DO";
|
|
30
|
+
const DEFAULT_SYNC_SHARD_MAP_KV_BINDING = "SYNC_SHARD_MAP_KV";
|
|
31
|
+
const DEFAULT_SYNC_AFFINITY_KEY = "x-concave-sync-affinity";
|
|
32
|
+
const DEFAULT_SYNC_REGION_KEY = "x-concave-region";
|
|
33
|
+
const DEFAULT_SYNC_DEFAULT_REGION = "global";
|
|
34
|
+
const DEFAULT_SHARD_MAP_CACHE_MS = 5_000;
|
|
35
|
+
const DEFAULT_NODE_STALE_MS = 90_000;
|
|
36
|
+
const DEFAULT_MIN_SHARDS = 1;
|
|
37
|
+
const DEFAULT_TARGET_SESSIONS_PER_SHARD = 500;
|
|
38
|
+
const DEFAULT_TARGET_CPU_UTILIZATION = 0.75;
|
|
39
|
+
const DEFAULT_TARGET_MEMORY_UTILIZATION = 0.8;
|
|
40
|
+
const DEFAULT_AUTO_SHARD_PREFIX = "auto";
|
|
41
|
+
const DEFAULT_SYNC_AUTOSCALE_PROFILE = "advanced";
|
|
42
|
+
const DEFAULT_SCALE_UP_COOLDOWN_MS = 15_000;
|
|
43
|
+
const DEFAULT_SCALE_DOWN_COOLDOWN_MS = 45_000;
|
|
44
|
+
const DEFAULT_SCALE_UP_HYSTERESIS_RATIO = 1.05;
|
|
45
|
+
const DEFAULT_SCALE_DOWN_HYSTERESIS_RATIO = 0.95;
|
|
46
|
+
const DEFAULT_MAX_SCALE_UP_STEP = 2;
|
|
47
|
+
const DEFAULT_MAX_SCALE_DOWN_STEP = 1;
|
|
48
|
+
export function createCfSyncTopologyRuntime(options) {
|
|
49
|
+
const logPrefix = options.logPrefix ?? "[sync-topology]";
|
|
50
|
+
const projectHeaderName = options.projectHeaderName ?? DEFAULT_PROJECT_HEADER;
|
|
51
|
+
const coordinatorBindingName = options.coordinatorBindingName ?? DEFAULT_SYNC_COORDINATOR_BINDING;
|
|
52
|
+
const shardMapKvBindingName = options.shardMapKvBindingName ?? DEFAULT_SYNC_SHARD_MAP_KV_BINDING;
|
|
53
|
+
const resolveProjectId = options.resolveProjectId ?? (() => undefined);
|
|
54
|
+
const buildShardMapCacheKey = options.buildShardMapCacheKey ??
|
|
55
|
+
((logicalInstance, projectId) => {
|
|
56
|
+
const normalizedProject = projectId?.trim() ?? "default";
|
|
57
|
+
return `${normalizedProject}:${logicalInstance}`;
|
|
58
|
+
});
|
|
59
|
+
const shardMapCache = new Map();
|
|
60
|
+
const shardMapInFlight = new Map();
|
|
61
|
+
async function resolveRouteTargets(context) {
|
|
62
|
+
const env = context.env;
|
|
63
|
+
const config = resolveTopologyConfig(env);
|
|
64
|
+
const projectId = resolveProjectId(context.request, env);
|
|
65
|
+
const region = resolveRequestRegion(context.request, env);
|
|
66
|
+
const shardMap = await resolveSyncShardMap({
|
|
67
|
+
request: context.request,
|
|
68
|
+
env,
|
|
69
|
+
logicalInstance: context.instance,
|
|
70
|
+
projectId,
|
|
71
|
+
region,
|
|
72
|
+
config,
|
|
73
|
+
});
|
|
74
|
+
const regionCandidates = resolveRegionShardCandidates(shardMap, region, config.defaultRegion);
|
|
75
|
+
const bootstrapCandidates = regionCandidates.length > 0
|
|
76
|
+
? regionCandidates
|
|
77
|
+
: resolveBootstrapShardCandidates(context.instance, region, config);
|
|
78
|
+
const syncDoName = selectDoName(context.request, context.instance, bootstrapCandidates, config.syncAffinityKey);
|
|
79
|
+
return {
|
|
80
|
+
syncDoName,
|
|
81
|
+
concaveDoName: context.instance,
|
|
82
|
+
syncNotifyDoNames: normalizeTargetList(shardMap.notifyShards),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
function resolveSyncFanoutDoNames(logicalInstance, env) {
|
|
86
|
+
const staticMap = resolveStaticSyncShardMap(logicalInstance, env);
|
|
87
|
+
return normalizeTargetList(staticMap.notifyShards);
|
|
88
|
+
}
|
|
89
|
+
async function resolveSyncFanoutDoNamesAsync(logicalInstance, env, optionsInput) {
|
|
90
|
+
const config = resolveTopologyConfig(env);
|
|
91
|
+
const request = optionsInput?.request ?? new Request("https://concave.invalid/sync");
|
|
92
|
+
const projectId = optionsInput?.projectId ?? resolveProjectId(request, env);
|
|
93
|
+
const region = resolveRequestRegion(request, env);
|
|
94
|
+
const shardMap = await resolveSyncShardMap({
|
|
95
|
+
request,
|
|
96
|
+
env,
|
|
97
|
+
logicalInstance,
|
|
98
|
+
projectId,
|
|
99
|
+
region,
|
|
100
|
+
config,
|
|
101
|
+
});
|
|
102
|
+
return normalizeTargetList(shardMap.notifyShards);
|
|
103
|
+
}
|
|
104
|
+
function resolveStaticSyncShardMap(logicalInstance, env, nowMs = Date.now()) {
|
|
105
|
+
const config = resolveTopologyConfig(env);
|
|
106
|
+
return resolveSharedStaticShardMap(logicalInstance, config, nowMs);
|
|
107
|
+
}
|
|
108
|
+
function buildShardMapFromReports(optionsInput) {
|
|
109
|
+
return buildSharedShardMapFromReports({
|
|
110
|
+
...optionsInput,
|
|
111
|
+
config: resolveTopologyConfig(optionsInput.env),
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
function resolveTopologyConfig(env) {
|
|
115
|
+
const syncNodes = parseNodeIds(env[DEFAULT_SYNC_NODE_IDS_ENV]);
|
|
116
|
+
const syncNodesByRegion = parseNodeIdsByRegion(env[DEFAULT_SYNC_NODE_IDS_BY_REGION_ENV]);
|
|
117
|
+
const syncAffinityKey = resolveHeaderLikeKey(env[DEFAULT_SYNC_AFFINITY_KEY_ENV], DEFAULT_SYNC_AFFINITY_KEY);
|
|
118
|
+
const regionKey = resolveHeaderLikeKey(env[DEFAULT_SYNC_REGION_KEY_ENV], DEFAULT_SYNC_REGION_KEY);
|
|
119
|
+
const defaultRegion = normalizeRegionName(env[DEFAULT_SYNC_DEFAULT_REGION_ENV], DEFAULT_SYNC_DEFAULT_REGION);
|
|
120
|
+
const regionByColo = parseRegionByColoMap(env[DEFAULT_SYNC_REGION_MAP_ENV]);
|
|
121
|
+
const useColoAsRegion = parseBoolean(env[DEFAULT_SYNC_USE_COLO_AS_REGION_ENV], false);
|
|
122
|
+
const scaleProfile = parseScaleProfile(env[DEFAULT_SYNC_AUTOSCALE_PROFILE_ENV], DEFAULT_SYNC_AUTOSCALE_PROFILE);
|
|
123
|
+
const allowScaleToZeroRequested = parseBoolean(env[DEFAULT_SYNC_SCALE_TO_ZERO_ENV], false);
|
|
124
|
+
const allowScaleToZero = allowScaleToZeroRequested && asDurableObjectNamespace(env[coordinatorBindingName]) !== undefined;
|
|
125
|
+
const shardMapCacheMs = parseIntWithMin(env[DEFAULT_SYNC_SHARD_MAP_CACHE_MS_ENV], DEFAULT_SHARD_MAP_CACHE_MS, 500);
|
|
126
|
+
const nodeStaleMs = parseIntWithMin(env[DEFAULT_SYNC_NODE_STALE_MS_ENV], DEFAULT_NODE_STALE_MS, 1_000);
|
|
127
|
+
const minShardFloor = allowScaleToZero ? 0 : 1;
|
|
128
|
+
const minShardsPerRegion = parseIntWithMin(env[DEFAULT_SYNC_MIN_SHARDS_ENV], allowScaleToZero ? 0 : DEFAULT_MIN_SHARDS, minShardFloor);
|
|
129
|
+
const maxShardsPerRegion = parseOptionalIntWithMin(env[DEFAULT_SYNC_MAX_SHARDS_ENV], minShardsPerRegion);
|
|
130
|
+
const targetSessionsPerShard = parseIntWithMin(env[DEFAULT_SYNC_TARGET_SESSIONS_ENV], DEFAULT_TARGET_SESSIONS_PER_SHARD, 1);
|
|
131
|
+
const targetMessageRatePerShard = parseOptionalIntWithMin(env[DEFAULT_SYNC_TARGET_MESSAGE_RATE_ENV], 1);
|
|
132
|
+
const targetNotifyRatePerShard = parseOptionalIntWithMin(env[DEFAULT_SYNC_TARGET_NOTIFY_RATE_ENV], 1);
|
|
133
|
+
const targetCpuUtilizationRaw = parseOptionalFloatWithRange(env[DEFAULT_SYNC_TARGET_CPU_UTILIZATION_ENV], 0.01, 1.5);
|
|
134
|
+
const targetMemoryUtilizationRaw = parseOptionalFloatWithRange(env[DEFAULT_SYNC_TARGET_MEMORY_UTILIZATION_ENV], 0.01, 1.5);
|
|
135
|
+
const targetCpuUtilization = targetCpuUtilizationRaw ?? (scaleProfile === "minimal" ? undefined : DEFAULT_TARGET_CPU_UTILIZATION);
|
|
136
|
+
const targetMemoryUtilization = targetMemoryUtilizationRaw ?? (scaleProfile === "minimal" ? undefined : DEFAULT_TARGET_MEMORY_UTILIZATION);
|
|
137
|
+
const autoShardsPerRegion = parseIntWithMin(env[DEFAULT_SYNC_AUTO_SHARDS_ENV], maxShardsPerRegion ?? minShardsPerRegion, 1);
|
|
138
|
+
const autoShardPrefix = resolveShardPrefix(env[DEFAULT_SYNC_AUTO_SHARD_PREFIX_ENV], DEFAULT_AUTO_SHARD_PREFIX);
|
|
139
|
+
const scaleUpCooldownMs = parseIntWithMin(env[DEFAULT_SYNC_SCALE_UP_COOLDOWN_MS_ENV], DEFAULT_SCALE_UP_COOLDOWN_MS, 0);
|
|
140
|
+
const scaleDownCooldownMs = parseIntWithMin(env[DEFAULT_SYNC_SCALE_DOWN_COOLDOWN_MS_ENV], DEFAULT_SCALE_DOWN_COOLDOWN_MS, 0);
|
|
141
|
+
const scaleUpHysteresisRatio = parseFloatWithRange(env[DEFAULT_SYNC_SCALE_UP_HYSTERESIS_RATIO_ENV], DEFAULT_SCALE_UP_HYSTERESIS_RATIO, 1, 3);
|
|
142
|
+
const scaleDownHysteresisRatio = parseFloatWithRange(env[DEFAULT_SYNC_SCALE_DOWN_HYSTERESIS_RATIO_ENV], DEFAULT_SCALE_DOWN_HYSTERESIS_RATIO, 0, 1);
|
|
143
|
+
const maxScaleUpStep = parseIntWithMin(env[DEFAULT_SYNC_MAX_SCALE_UP_STEP_ENV], DEFAULT_MAX_SCALE_UP_STEP, 1);
|
|
144
|
+
const maxScaleDownStep = parseIntWithMin(env[DEFAULT_SYNC_MAX_SCALE_DOWN_STEP_ENV], DEFAULT_MAX_SCALE_DOWN_STEP, 1);
|
|
145
|
+
return {
|
|
146
|
+
syncNodes,
|
|
147
|
+
syncNodesByRegion,
|
|
148
|
+
syncAffinityKey,
|
|
149
|
+
regionKey,
|
|
150
|
+
defaultRegion,
|
|
151
|
+
regionByColo,
|
|
152
|
+
useColoAsRegion,
|
|
153
|
+
shardMapCacheMs,
|
|
154
|
+
nodeStaleMs,
|
|
155
|
+
minShardsPerRegion,
|
|
156
|
+
maxShardsPerRegion,
|
|
157
|
+
targetSessionsPerShard,
|
|
158
|
+
targetMessageRatePerShard,
|
|
159
|
+
targetNotifyRatePerShard,
|
|
160
|
+
targetCpuUtilization,
|
|
161
|
+
targetMemoryUtilization,
|
|
162
|
+
autoShardsPerRegion,
|
|
163
|
+
autoShardPrefix,
|
|
164
|
+
allowScaleToZero,
|
|
165
|
+
scaleProfile,
|
|
166
|
+
scaleUpCooldownMs,
|
|
167
|
+
scaleDownCooldownMs,
|
|
168
|
+
scaleUpHysteresisRatio,
|
|
169
|
+
scaleDownHysteresisRatio,
|
|
170
|
+
maxScaleUpStep,
|
|
171
|
+
maxScaleDownStep,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function resolveRequestRegion(request, env) {
|
|
175
|
+
const config = resolveTopologyConfig(env);
|
|
176
|
+
const regionFromRequest = resolveHeaderQueryOrCookieValue(request, config.regionKey);
|
|
177
|
+
if (regionFromRequest && regionFromRequest.length > 0) {
|
|
178
|
+
return normalizeRegionName(regionFromRequest, config.defaultRegion);
|
|
179
|
+
}
|
|
180
|
+
const requestWithCf = request;
|
|
181
|
+
const colo = requestWithCf.cf?.colo?.trim();
|
|
182
|
+
if (colo && colo.length > 0) {
|
|
183
|
+
const mapped = config.regionByColo[colo.toUpperCase()];
|
|
184
|
+
if (mapped) {
|
|
185
|
+
return mapped;
|
|
186
|
+
}
|
|
187
|
+
if (config.useColoAsRegion) {
|
|
188
|
+
return normalizeRegionName(colo, config.defaultRegion);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
return config.defaultRegion;
|
|
192
|
+
}
|
|
193
|
+
async function resolveSyncShardMap(context) {
|
|
194
|
+
const cacheKey = buildShardMapCacheKey(context.logicalInstance, context.projectId);
|
|
195
|
+
const nowMs = Date.now();
|
|
196
|
+
const cached = shardMapCache.get(cacheKey);
|
|
197
|
+
if (cached && cached.expiresAtMs > nowMs) {
|
|
198
|
+
return cached.value;
|
|
199
|
+
}
|
|
200
|
+
const inFlight = shardMapInFlight.get(cacheKey);
|
|
201
|
+
if (inFlight) {
|
|
202
|
+
return inFlight;
|
|
203
|
+
}
|
|
204
|
+
const resolution = resolveSyncShardMapUncached(context).then((resolved) => {
|
|
205
|
+
const ttlMs = parseIntWithMin(resolved.ttlMs, context.config.shardMapCacheMs, 500);
|
|
206
|
+
const normalized = {
|
|
207
|
+
...resolved,
|
|
208
|
+
ttlMs,
|
|
209
|
+
};
|
|
210
|
+
shardMapCache.set(cacheKey, {
|
|
211
|
+
expiresAtMs: Date.now() + ttlMs,
|
|
212
|
+
value: normalized,
|
|
213
|
+
});
|
|
214
|
+
return normalized;
|
|
215
|
+
});
|
|
216
|
+
shardMapInFlight.set(cacheKey, resolution);
|
|
217
|
+
try {
|
|
218
|
+
return await resolution;
|
|
219
|
+
}
|
|
220
|
+
finally {
|
|
221
|
+
shardMapInFlight.delete(cacheKey);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
async function resolveSyncShardMapUncached(context) {
|
|
225
|
+
const fromKv = await tryReadShardMapFromKv(context);
|
|
226
|
+
if (fromKv) {
|
|
227
|
+
return fromKv;
|
|
228
|
+
}
|
|
229
|
+
const fromCoordinator = await tryReadShardMapFromCoordinator(context);
|
|
230
|
+
if (fromCoordinator) {
|
|
231
|
+
return fromCoordinator;
|
|
232
|
+
}
|
|
233
|
+
return resolveStaticSyncShardMap(context.logicalInstance, context.env);
|
|
234
|
+
}
|
|
235
|
+
async function tryReadShardMapFromKv(context) {
|
|
236
|
+
const kv = asKvNamespace(context.env[shardMapKvBindingName]);
|
|
237
|
+
if (!kv) {
|
|
238
|
+
return undefined;
|
|
239
|
+
}
|
|
240
|
+
try {
|
|
241
|
+
const key = options.buildShardMapKvKey(context.logicalInstance, context.projectId);
|
|
242
|
+
const raw = await kv.get(key, { type: "json" });
|
|
243
|
+
const parsed = parseShardMap(raw, context.logicalInstance);
|
|
244
|
+
if (!parsed) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
return { ...parsed, source: "kv" };
|
|
248
|
+
}
|
|
249
|
+
catch (error) {
|
|
250
|
+
console.warn(`${logPrefix} Failed to read sync shard map from KV`, error);
|
|
251
|
+
return undefined;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
async function tryReadShardMapFromCoordinator(context) {
|
|
255
|
+
const coordinatorNamespace = asDurableObjectNamespace(context.env[coordinatorBindingName]);
|
|
256
|
+
if (!coordinatorNamespace) {
|
|
257
|
+
return undefined;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
const coordinatorName = options.buildCoordinatorDoName(context.logicalInstance, context.projectId);
|
|
261
|
+
const coordinatorId = coordinatorNamespace.idFromName(coordinatorName);
|
|
262
|
+
const coordinatorStub = coordinatorNamespace.get(coordinatorId);
|
|
263
|
+
const headers = new Headers({
|
|
264
|
+
"X-Concave-Instance": context.logicalInstance,
|
|
265
|
+
"X-Concave-Region": context.region,
|
|
266
|
+
});
|
|
267
|
+
if (context.projectId) {
|
|
268
|
+
headers.set(projectHeaderName, context.projectId);
|
|
269
|
+
}
|
|
270
|
+
const response = await coordinatorStub.fetch(`http://do/shard-map?region=${encodeURIComponent(context.region)}`, {
|
|
271
|
+
method: "GET",
|
|
272
|
+
headers,
|
|
273
|
+
});
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
return undefined;
|
|
276
|
+
}
|
|
277
|
+
const body = (await response.json());
|
|
278
|
+
const parsed = parseShardMap(body, context.logicalInstance);
|
|
279
|
+
if (!parsed) {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
return { ...parsed, source: "coordinator" };
|
|
283
|
+
}
|
|
284
|
+
catch (error) {
|
|
285
|
+
console.warn(`${logPrefix} Failed to read sync shard map from coordinator`, error);
|
|
286
|
+
return undefined;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return {
|
|
290
|
+
resolveRouteTargets,
|
|
291
|
+
resolveSyncFanoutDoNames,
|
|
292
|
+
resolveSyncFanoutDoNamesAsync,
|
|
293
|
+
resolveStaticSyncShardMap,
|
|
294
|
+
buildShardMapFromReports,
|
|
295
|
+
resolveTopologyConfig,
|
|
296
|
+
resolveRequestRegion,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
export function buildPooledInstanceName(logicalInstance, poolKind, nodeId) {
|
|
300
|
+
return buildSharedPooledInstanceName(logicalInstance, poolKind, nodeId);
|
|
301
|
+
}
|
|
302
|
+
export function parseNodeIds(raw) {
|
|
303
|
+
return parseSharedNodeIds(raw);
|
|
304
|
+
}
|
|
305
|
+
export function parseNodeIdsByRegion(raw) {
|
|
306
|
+
return parseSharedNodeIdsByRegion(raw);
|
|
307
|
+
}
|
|
308
|
+
export function normalizeTargetList(targets) {
|
|
309
|
+
return normalizeSharedTargetList(targets);
|
|
310
|
+
}
|
|
311
|
+
function parseShardMap(raw, fallbackInstance) {
|
|
312
|
+
if (isValidSyncShardMap(raw)) {
|
|
313
|
+
return normalizeParsedShardMap(raw, fallbackInstance);
|
|
314
|
+
}
|
|
315
|
+
const data = parseRecordLike(raw);
|
|
316
|
+
if (!data) {
|
|
317
|
+
return undefined;
|
|
318
|
+
}
|
|
319
|
+
const regionsRaw = parseRecordLike(data.regions);
|
|
320
|
+
const regions = {};
|
|
321
|
+
if (regionsRaw) {
|
|
322
|
+
for (const [region, value] of Object.entries(regionsRaw)) {
|
|
323
|
+
const normalizedRegion = normalizeRegionName(region, "");
|
|
324
|
+
if (!normalizedRegion) {
|
|
325
|
+
continue;
|
|
326
|
+
}
|
|
327
|
+
const targets = parseNodeIds(value);
|
|
328
|
+
if (targets.length > 0 || isExplicitEmptyNodeList(value)) {
|
|
329
|
+
regions[normalizedRegion] = targets;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
const notifySource = data.notifyShards ?? data.notify;
|
|
334
|
+
const notifyShards = normalizeTargetList(parseNodeIds(notifySource));
|
|
335
|
+
if (Object.keys(regions).length === 0) {
|
|
336
|
+
regions[DEFAULT_SYNC_DEFAULT_REGION] = [fallbackInstance];
|
|
337
|
+
}
|
|
338
|
+
const ttlMs = parseIntWithMin(data.ttlMs, DEFAULT_SHARD_MAP_CACHE_MS, 500);
|
|
339
|
+
const generatedAtMs = parseIntWithMin(data.generatedAtMs, Date.now(), 0);
|
|
340
|
+
return normalizeParsedShardMap({
|
|
341
|
+
generatedAtMs,
|
|
342
|
+
ttlMs,
|
|
343
|
+
regions,
|
|
344
|
+
notifyShards,
|
|
345
|
+
}, fallbackInstance);
|
|
346
|
+
}
|
|
347
|
+
function normalizeParsedShardMap(shardMap, fallbackInstance) {
|
|
348
|
+
const regions = {};
|
|
349
|
+
for (const [region, targets] of Object.entries(shardMap.regions)) {
|
|
350
|
+
const normalizedRegion = normalizeRegionName(region, "");
|
|
351
|
+
if (!normalizedRegion) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
regions[normalizedRegion] = normalizeTargetList(targets);
|
|
355
|
+
}
|
|
356
|
+
if (Object.keys(regions).length === 0) {
|
|
357
|
+
regions[DEFAULT_SYNC_DEFAULT_REGION] = [fallbackInstance];
|
|
358
|
+
}
|
|
359
|
+
return {
|
|
360
|
+
...shardMap,
|
|
361
|
+
generatedAtMs: parseIntWithMin(shardMap.generatedAtMs, Date.now(), 0),
|
|
362
|
+
ttlMs: parseIntWithMin(shardMap.ttlMs, DEFAULT_SHARD_MAP_CACHE_MS, 500),
|
|
363
|
+
regions,
|
|
364
|
+
notifyShards: normalizeTargetList(shardMap.notifyShards),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function parseRegionByColoMap(raw) {
|
|
368
|
+
const parsed = parseRecordLike(raw);
|
|
369
|
+
if (!parsed) {
|
|
370
|
+
return {};
|
|
371
|
+
}
|
|
372
|
+
const result = {};
|
|
373
|
+
for (const [colo, regionValue] of Object.entries(parsed)) {
|
|
374
|
+
if (typeof regionValue !== "string") {
|
|
375
|
+
continue;
|
|
376
|
+
}
|
|
377
|
+
const normalizedRegion = normalizeRegionName(regionValue, "");
|
|
378
|
+
if (!normalizedRegion) {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
result[colo.trim().toUpperCase()] = normalizedRegion;
|
|
382
|
+
}
|
|
383
|
+
return result;
|
|
384
|
+
}
|
|
385
|
+
function parseRecordLike(raw) {
|
|
386
|
+
if (typeof raw === "object" && raw !== null && !Array.isArray(raw)) {
|
|
387
|
+
return raw;
|
|
388
|
+
}
|
|
389
|
+
if (typeof raw !== "string") {
|
|
390
|
+
return undefined;
|
|
391
|
+
}
|
|
392
|
+
const trimmed = raw.trim();
|
|
393
|
+
if (trimmed.length === 0) {
|
|
394
|
+
return undefined;
|
|
395
|
+
}
|
|
396
|
+
try {
|
|
397
|
+
const parsed = JSON.parse(trimmed);
|
|
398
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
399
|
+
return parsed;
|
|
400
|
+
}
|
|
401
|
+
return undefined;
|
|
402
|
+
}
|
|
403
|
+
catch {
|
|
404
|
+
const result = {};
|
|
405
|
+
for (const token of trimmed.split(";")) {
|
|
406
|
+
const normalizedToken = token.trim();
|
|
407
|
+
if (!normalizedToken) {
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const equalsIndex = normalizedToken.indexOf("=");
|
|
411
|
+
const colonIndex = normalizedToken.indexOf(":");
|
|
412
|
+
const separatorIndex = equalsIndex === -1 ? colonIndex : equalsIndex;
|
|
413
|
+
if (separatorIndex === -1) {
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
const key = normalizedToken.slice(0, separatorIndex).trim();
|
|
417
|
+
const value = normalizedToken.slice(separatorIndex + 1).trim();
|
|
418
|
+
if (!key || !value) {
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
result[key] = value;
|
|
422
|
+
}
|
|
423
|
+
return result;
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
function isExplicitEmptyNodeList(raw) {
|
|
427
|
+
if (Array.isArray(raw)) {
|
|
428
|
+
return raw.length === 0;
|
|
429
|
+
}
|
|
430
|
+
if (typeof raw !== "string") {
|
|
431
|
+
return false;
|
|
432
|
+
}
|
|
433
|
+
return raw.trim() === "[]";
|
|
434
|
+
}
|
|
435
|
+
function resolveRegionShardCandidates(shardMap, region, defaultRegion) {
|
|
436
|
+
const normalizedRegion = normalizeRegionName(region, defaultRegion);
|
|
437
|
+
const directMatch = shardMap.regions[normalizedRegion];
|
|
438
|
+
if (directMatch) {
|
|
439
|
+
return normalizeTargetList(directMatch);
|
|
440
|
+
}
|
|
441
|
+
const defaultMatch = shardMap.regions[defaultRegion];
|
|
442
|
+
if (defaultMatch) {
|
|
443
|
+
return normalizeTargetList(defaultMatch);
|
|
444
|
+
}
|
|
445
|
+
const firstRegion = Object.values(shardMap.regions).find((targets) => targets.length > 0);
|
|
446
|
+
return normalizeTargetList(firstRegion);
|
|
447
|
+
}
|
|
448
|
+
function selectDoName(request, fallbackInstance, targets, affinityKey) {
|
|
449
|
+
const candidates = normalizeTargets(targets, fallbackInstance);
|
|
450
|
+
if (candidates.length <= 1) {
|
|
451
|
+
return candidates[0] ?? fallbackInstance;
|
|
452
|
+
}
|
|
453
|
+
const affinity = resolveAffinityValue(request, affinityKey, fallbackInstance);
|
|
454
|
+
return pickNodeByRendezvousHash(affinity, candidates);
|
|
455
|
+
}
|
|
456
|
+
function resolveAffinityValue(request, affinityKey, fallback) {
|
|
457
|
+
const affinity = resolveHeaderQueryOrCookieValue(request, affinityKey);
|
|
458
|
+
if (affinity && affinity.length > 0) {
|
|
459
|
+
return affinity;
|
|
460
|
+
}
|
|
461
|
+
const authHeader = request.headers.get("Authorization");
|
|
462
|
+
if (authHeader && authHeader.length > 0) {
|
|
463
|
+
return authHeader;
|
|
464
|
+
}
|
|
465
|
+
const clientIp = request.headers.get("CF-Connecting-IP") ?? request.headers.get("X-Forwarded-For");
|
|
466
|
+
if (clientIp && clientIp.length > 0) {
|
|
467
|
+
return clientIp;
|
|
468
|
+
}
|
|
469
|
+
const userAgent = request.headers.get("User-Agent");
|
|
470
|
+
if (userAgent && userAgent.length > 0) {
|
|
471
|
+
return userAgent;
|
|
472
|
+
}
|
|
473
|
+
return fallback;
|
|
474
|
+
}
|
|
475
|
+
function resolveHeaderQueryOrCookieValue(request, key) {
|
|
476
|
+
const url = new URL(request.url);
|
|
477
|
+
const queryValue = url.searchParams.get(key);
|
|
478
|
+
if (queryValue && queryValue.length > 0) {
|
|
479
|
+
return queryValue;
|
|
480
|
+
}
|
|
481
|
+
const headerValue = request.headers.get(key);
|
|
482
|
+
if (headerValue && headerValue.length > 0) {
|
|
483
|
+
return headerValue;
|
|
484
|
+
}
|
|
485
|
+
const cookieValue = readCookieValue(request.headers.get("Cookie"), key);
|
|
486
|
+
if (cookieValue && cookieValue.length > 0) {
|
|
487
|
+
return cookieValue;
|
|
488
|
+
}
|
|
489
|
+
return undefined;
|
|
490
|
+
}
|
|
491
|
+
function readCookieValue(cookieHeader, name) {
|
|
492
|
+
if (!cookieHeader) {
|
|
493
|
+
return undefined;
|
|
494
|
+
}
|
|
495
|
+
const target = name.toLowerCase();
|
|
496
|
+
const entries = cookieHeader.split(";").map((part) => part.trim());
|
|
497
|
+
for (const entry of entries) {
|
|
498
|
+
if (!entry) {
|
|
499
|
+
continue;
|
|
500
|
+
}
|
|
501
|
+
const equals = entry.indexOf("=");
|
|
502
|
+
if (equals === -1) {
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const key = entry.slice(0, equals).trim().toLowerCase();
|
|
506
|
+
if (key !== target) {
|
|
507
|
+
continue;
|
|
508
|
+
}
|
|
509
|
+
return decodeURIComponent(entry.slice(equals + 1).trim());
|
|
510
|
+
}
|
|
511
|
+
return undefined;
|
|
512
|
+
}
|
|
513
|
+
function resolveBootstrapShardCandidates(logicalInstance, region, config) {
|
|
514
|
+
return resolveBootstrapSyncShardCandidates(logicalInstance, region, config);
|
|
515
|
+
}
|
|
516
|
+
function normalizeTargets(targets, fallback) {
|
|
517
|
+
const normalized = normalizeTargetList(targets);
|
|
518
|
+
return normalized.length > 0 ? normalized : [fallback];
|
|
519
|
+
}
|
|
520
|
+
function normalizeRegionName(raw, fallback) {
|
|
521
|
+
if (typeof raw !== "string") {
|
|
522
|
+
return fallback;
|
|
523
|
+
}
|
|
524
|
+
const normalized = raw.trim().toLowerCase();
|
|
525
|
+
return normalized.length > 0 ? normalized : fallback;
|
|
526
|
+
}
|
|
527
|
+
function asDurableObjectNamespace(raw) {
|
|
528
|
+
if (!raw || typeof raw !== "object") {
|
|
529
|
+
return undefined;
|
|
530
|
+
}
|
|
531
|
+
const candidate = raw;
|
|
532
|
+
if (typeof candidate.idFromName !== "function" || typeof candidate.get !== "function") {
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
return candidate;
|
|
536
|
+
}
|
|
537
|
+
function asKvNamespace(raw) {
|
|
538
|
+
if (!raw || typeof raw !== "object") {
|
|
539
|
+
return undefined;
|
|
540
|
+
}
|
|
541
|
+
const candidate = raw;
|
|
542
|
+
if (typeof candidate.get !== "function") {
|
|
543
|
+
return undefined;
|
|
544
|
+
}
|
|
545
|
+
return candidate;
|
|
546
|
+
}
|
|
547
|
+
function resolveHeaderLikeKey(raw, fallback) {
|
|
548
|
+
if (typeof raw !== "string") {
|
|
549
|
+
return fallback;
|
|
550
|
+
}
|
|
551
|
+
const trimmed = raw.trim().toLowerCase();
|
|
552
|
+
return trimmed.length > 0 ? trimmed : fallback;
|
|
553
|
+
}
|
|
554
|
+
function parseBoolean(raw, fallback) {
|
|
555
|
+
if (typeof raw === "boolean") {
|
|
556
|
+
return raw;
|
|
557
|
+
}
|
|
558
|
+
if (typeof raw !== "string") {
|
|
559
|
+
return fallback;
|
|
560
|
+
}
|
|
561
|
+
const normalized = raw.trim().toLowerCase();
|
|
562
|
+
if (normalized === "1" || normalized === "true" || normalized === "yes") {
|
|
563
|
+
return true;
|
|
564
|
+
}
|
|
565
|
+
if (normalized === "0" || normalized === "false" || normalized === "no") {
|
|
566
|
+
return false;
|
|
567
|
+
}
|
|
568
|
+
return fallback;
|
|
569
|
+
}
|
|
570
|
+
function resolveShardPrefix(raw, fallback) {
|
|
571
|
+
if (typeof raw !== "string") {
|
|
572
|
+
return fallback;
|
|
573
|
+
}
|
|
574
|
+
const normalized = raw
|
|
575
|
+
.trim()
|
|
576
|
+
.toLowerCase()
|
|
577
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
578
|
+
.replace(/-+/g, "-")
|
|
579
|
+
.replace(/^-|-$/g, "");
|
|
580
|
+
return normalized.length > 0 ? normalized : fallback;
|
|
581
|
+
}
|
|
582
|
+
function parseIntWithMin(raw, fallback, minimum) {
|
|
583
|
+
const value = parseNumber(raw);
|
|
584
|
+
if (value === undefined) {
|
|
585
|
+
return Math.max(minimum, fallback);
|
|
586
|
+
}
|
|
587
|
+
return Math.max(minimum, value);
|
|
588
|
+
}
|
|
589
|
+
function parseOptionalIntWithMin(raw, minimum) {
|
|
590
|
+
const value = parseNumber(raw);
|
|
591
|
+
if (value === undefined) {
|
|
592
|
+
return undefined;
|
|
593
|
+
}
|
|
594
|
+
return Math.max(minimum, value);
|
|
595
|
+
}
|
|
596
|
+
function parseOptionalFloatWithRange(raw, minimum, maximum) {
|
|
597
|
+
const value = parseFloatNumber(raw);
|
|
598
|
+
if (value === undefined) {
|
|
599
|
+
return undefined;
|
|
600
|
+
}
|
|
601
|
+
return clampNumber(value, minimum, maximum);
|
|
602
|
+
}
|
|
603
|
+
function parseFloatWithRange(raw, fallback, minimum, maximum) {
|
|
604
|
+
const value = parseFloatNumber(raw);
|
|
605
|
+
if (value === undefined) {
|
|
606
|
+
return clampNumber(fallback, minimum, maximum);
|
|
607
|
+
}
|
|
608
|
+
return clampNumber(value, minimum, maximum);
|
|
609
|
+
}
|
|
610
|
+
function parseNumber(raw) {
|
|
611
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
612
|
+
return Math.floor(raw);
|
|
613
|
+
}
|
|
614
|
+
if (typeof raw !== "string") {
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
const trimmed = raw.trim();
|
|
618
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
return Number(trimmed);
|
|
622
|
+
}
|
|
623
|
+
function parseFloatNumber(raw) {
|
|
624
|
+
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
625
|
+
return raw;
|
|
626
|
+
}
|
|
627
|
+
if (typeof raw !== "string") {
|
|
628
|
+
return undefined;
|
|
629
|
+
}
|
|
630
|
+
const trimmed = raw.trim();
|
|
631
|
+
if (!/^\d+(?:\.\d+)?$/.test(trimmed)) {
|
|
632
|
+
return undefined;
|
|
633
|
+
}
|
|
634
|
+
const parsed = Number(trimmed);
|
|
635
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
636
|
+
}
|
|
637
|
+
function parseScaleProfile(raw, fallback) {
|
|
638
|
+
if (typeof raw !== "string") {
|
|
639
|
+
return fallback;
|
|
640
|
+
}
|
|
641
|
+
const normalized = raw.trim().toLowerCase();
|
|
642
|
+
if (normalized === "minimal" || normalized === "advanced" || normalized === "custom") {
|
|
643
|
+
return normalized;
|
|
644
|
+
}
|
|
645
|
+
return fallback;
|
|
646
|
+
}
|
|
647
|
+
function clampNumber(value, minimum, maximum) {
|
|
648
|
+
return Math.max(minimum, Math.min(maximum, value));
|
|
649
|
+
}
|
|
650
|
+
function pickNodeByRendezvousHash(affinity, nodes) {
|
|
651
|
+
let selected = nodes[0];
|
|
652
|
+
let bestScore = -1;
|
|
653
|
+
for (const nodeId of nodes) {
|
|
654
|
+
const score = fnv1a32(`${affinity}:${nodeId}`);
|
|
655
|
+
if (score > bestScore || (score === bestScore && nodeId > selected)) {
|
|
656
|
+
bestScore = score;
|
|
657
|
+
selected = nodeId;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return selected;
|
|
661
|
+
}
|
|
662
|
+
function fnv1a32(input) {
|
|
663
|
+
let hash = 0x811c9dc5;
|
|
664
|
+
for (let i = 0; i < input.length; i += 1) {
|
|
665
|
+
hash ^= input.charCodeAt(i);
|
|
666
|
+
hash = Math.imul(hash, 0x01000193);
|
|
667
|
+
}
|
|
668
|
+
return hash >>> 0;
|
|
669
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DurableObjectNamespace, ExecutionContext } from "@cloudflare/workers-types";
|
|
2
2
|
import { handleHttpApiRequest } from "../http/http-api";
|
|
3
|
+
import type { SyncWriteDispatchPayload } from "../http/http-api";
|
|
3
4
|
import { type ModuleLoader, type ModuleRegistry } from "@concavejs/core/udf";
|
|
4
5
|
export interface ConcaveWorkerBindings {
|
|
5
6
|
getConcaveNamespace: (env: any, ctx: ExecutionContext) => DurableObjectNamespace | undefined;
|
|
@@ -25,6 +26,50 @@ export interface ConcaveWorkerOptions {
|
|
|
25
26
|
configureModuleLoaders?: (registry: ModuleRegistry) => void;
|
|
26
27
|
/** Pre-loaded dashboard HTML (avoids bundling dashboard-local) */
|
|
27
28
|
dashboardHtml?: string;
|
|
29
|
+
/**
|
|
30
|
+
* Optional per-request routing overrides for selecting physical DO targets.
|
|
31
|
+
*
|
|
32
|
+
* `instance` remains the logical tenant/app instance used for auth/data scoping.
|
|
33
|
+
* Route targets can point at a pooled DO name for scaling.
|
|
34
|
+
*/
|
|
35
|
+
resolveRouteTargets?: (context: ConcaveWorkerRouteContext) => ConcaveWorkerRouteTargets | Promise<ConcaveWorkerRouteTargets | undefined> | undefined;
|
|
36
|
+
}
|
|
37
|
+
export interface ConcaveWorkerRouteContext<Env = any> {
|
|
38
|
+
request: Request;
|
|
39
|
+
env: Env;
|
|
40
|
+
ctx: ExecutionContext;
|
|
41
|
+
/** Logical instance selected via query/header/cookie routing. */
|
|
42
|
+
instance: string;
|
|
43
|
+
}
|
|
44
|
+
export interface ConcaveWorkerRouteTargets {
|
|
45
|
+
/** Physical ConcaveDO name to route /api and /udf requests to. */
|
|
46
|
+
concaveDoName?: string;
|
|
47
|
+
/** Physical SyncDO name to route /sync websocket connections to. */
|
|
48
|
+
syncDoName?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Physical SyncDO names that should receive mutation write notifications.
|
|
51
|
+
* Defaults to `[syncDoName]`.
|
|
52
|
+
*/
|
|
53
|
+
syncNotifyDoNames?: string[];
|
|
54
|
+
/**
|
|
55
|
+
* Structured target descriptor for runtime adapters.
|
|
56
|
+
* `targetName` maps to ConcaveDO in Cloudflare runtime.
|
|
57
|
+
*/
|
|
58
|
+
concave?: {
|
|
59
|
+
targetName?: string;
|
|
60
|
+
};
|
|
61
|
+
/**
|
|
62
|
+
* Structured sync target descriptor for runtime adapters.
|
|
63
|
+
* `targetName` maps to SyncDO, and `notifyTargetNames` maps to fanout targets.
|
|
64
|
+
*/
|
|
65
|
+
sync?: {
|
|
66
|
+
targetName?: string;
|
|
67
|
+
notifyTargetNames?: string[];
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Optional write-dispatch override (queue/stream fanout) for API mutations.
|
|
71
|
+
*/
|
|
72
|
+
dispatchSyncWrites?: (payload: SyncWriteDispatchPayload) => Promise<void>;
|
|
28
73
|
}
|
|
29
74
|
export declare function resolveNamespaceBinding(env: any, ctx: ExecutionContext, bindingName: string, exportName: string): DurableObjectNamespace | undefined;
|
|
30
75
|
export declare function createScopedNamespace(ns: DurableObjectNamespace, prefix: string): DurableObjectNamespace;
|
|
@@ -22,7 +22,7 @@ export function createScopedNamespace(ns, prefix) {
|
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
24
|
export function createConcaveWorker(options) {
|
|
25
|
-
const { bindings, instanceKey = DEFAULT_INSTANCE_KEY, cors = true, apiPrefix = "/api", udfPrefix = "/udf", syncPathMatcher = DEFAULT_SYNC_PATH_MATCHER, handleHttpApiRequest: apiHandler = handleHttpApiRequest, moduleLoader, moduleLoaders, configureModuleLoaders, dashboardHtml: injectedDashboardHtml, } = options;
|
|
25
|
+
const { bindings, instanceKey = DEFAULT_INSTANCE_KEY, cors = true, apiPrefix = "/api", udfPrefix = "/udf", syncPathMatcher = DEFAULT_SYNC_PATH_MATCHER, handleHttpApiRequest: apiHandler = handleHttpApiRequest, moduleLoader, moduleLoaders, configureModuleLoaders, dashboardHtml: injectedDashboardHtml, resolveRouteTargets, } = options;
|
|
26
26
|
let cachedDashboardHtml = injectedDashboardHtml;
|
|
27
27
|
const explicitLoaders = collectModuleLoaders(moduleLoader, moduleLoaders);
|
|
28
28
|
if (explicitLoaders.length > 0) {
|
|
@@ -49,6 +49,10 @@ export function createConcaveWorker(options) {
|
|
|
49
49
|
});
|
|
50
50
|
const instance = instanceResolution.value;
|
|
51
51
|
const forwardUrl = new URL(url.toString());
|
|
52
|
+
const routeTargets = (await resolveRouteTargets?.({ request, env, ctx, instance })) ?? {};
|
|
53
|
+
const concaveDoName = normalizeRouteTargetName(routeTargets.concaveDoName ?? routeTargets.concave?.targetName, instance);
|
|
54
|
+
const syncDoName = normalizeRouteTargetName(routeTargets.syncDoName ?? routeTargets.sync?.targetName, instance);
|
|
55
|
+
const syncNotifyDoNames = normalizeSyncNotifyTargets(routeTargets.syncNotifyDoNames ?? routeTargets.sync?.notifyTargetNames, syncDoName);
|
|
52
56
|
if (cors && request.method === "OPTIONS") {
|
|
53
57
|
return new Response("OK", {
|
|
54
58
|
headers: buildCorsHeaders(request),
|
|
@@ -63,10 +67,11 @@ export function createConcaveWorker(options) {
|
|
|
63
67
|
console.error("[ConcaveWorker] Sync Durable Object namespace is not configured");
|
|
64
68
|
throw new Error("Sync Durable Object namespace is not configured");
|
|
65
69
|
}
|
|
66
|
-
const id = namespace.idFromName(
|
|
70
|
+
const id = namespace.idFromName(syncDoName);
|
|
67
71
|
const stub = namespace.get(id);
|
|
68
|
-
console.log(`[ConcaveWorker] Forwarding to SyncDO:
|
|
72
|
+
console.log(`[ConcaveWorker] Forwarding to SyncDO: logical=${instance} physical=${syncDoName} (${id.toString()})`);
|
|
69
73
|
const forwardedRequest = new Request(forwardUrl.toString(), request);
|
|
74
|
+
forwardedRequest.headers.set("X-Concave-Instance", instance);
|
|
70
75
|
if (request.headers.get("Upgrade")) {
|
|
71
76
|
forwardedRequest.headers.set("Upgrade", request.headers.get("Upgrade"));
|
|
72
77
|
}
|
|
@@ -85,7 +90,12 @@ export function createConcaveWorker(options) {
|
|
|
85
90
|
}
|
|
86
91
|
if (pathname.startsWith(apiPrefix)) {
|
|
87
92
|
const forwardedRequest = new Request(forwardUrl.toString(), request);
|
|
88
|
-
|
|
93
|
+
forwardedRequest.headers.set("X-Concave-Instance", instance);
|
|
94
|
+
const response = await apiHandler(forwardedRequest, env, ctx, instance, {
|
|
95
|
+
concaveDoName,
|
|
96
|
+
syncDoNames: syncNotifyDoNames,
|
|
97
|
+
dispatchSyncWrites: routeTargets.dispatchSyncWrites,
|
|
98
|
+
});
|
|
89
99
|
return maybeAttachInstanceCookie(response, request, instanceResolution, { instanceKey });
|
|
90
100
|
}
|
|
91
101
|
if (pathname.startsWith(udfPrefix)) {
|
|
@@ -93,9 +103,10 @@ export function createConcaveWorker(options) {
|
|
|
93
103
|
if (!namespace) {
|
|
94
104
|
throw new Error("Concave Durable Object namespace is not configured");
|
|
95
105
|
}
|
|
96
|
-
const id = namespace.idFromName(
|
|
106
|
+
const id = namespace.idFromName(concaveDoName);
|
|
97
107
|
const stub = namespace.get(id);
|
|
98
108
|
const forwardedRequest = new Request(forwardUrl.toString(), request);
|
|
109
|
+
forwardedRequest.headers.set("X-Concave-Instance", instance);
|
|
99
110
|
const response = await stub.fetch(forwardedRequest);
|
|
100
111
|
return maybeAttachInstanceCookie(response, request, instanceResolution, { instanceKey });
|
|
101
112
|
}
|
|
@@ -160,3 +171,23 @@ function collectModuleLoaders(primary, additional) {
|
|
|
160
171
|
function isDashboardPath(pathname) {
|
|
161
172
|
return pathname === "/_dashboard" || pathname.startsWith("/_dashboard/");
|
|
162
173
|
}
|
|
174
|
+
function normalizeRouteTargetName(target, fallback) {
|
|
175
|
+
const trimmed = target?.trim();
|
|
176
|
+
return trimmed && trimmed.length > 0 ? trimmed : fallback;
|
|
177
|
+
}
|
|
178
|
+
function normalizeSyncNotifyTargets(targets, fallback) {
|
|
179
|
+
if (targets === undefined) {
|
|
180
|
+
return [fallback];
|
|
181
|
+
}
|
|
182
|
+
if (targets.length === 0) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const deduped = new Set();
|
|
186
|
+
for (const target of targets) {
|
|
187
|
+
const trimmed = target.trim();
|
|
188
|
+
if (trimmed.length > 0) {
|
|
189
|
+
deduped.add(trimmed);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return Array.from(deduped);
|
|
193
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@concavejs/runtime-cf-base",
|
|
3
|
-
"version": "0.0.1-alpha.
|
|
3
|
+
"version": "0.0.1-alpha.9",
|
|
4
4
|
"license": "FSL-1.1-Apache-2.0",
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -68,6 +68,10 @@
|
|
|
68
68
|
"types": "./dist/worker/udf-worker.d.ts",
|
|
69
69
|
"default": "./dist/worker/udf-worker.js"
|
|
70
70
|
},
|
|
71
|
+
"./routing/sync-topology": {
|
|
72
|
+
"types": "./dist/routing/sync-topology.d.ts",
|
|
73
|
+
"default": "./dist/routing/sync-topology.js"
|
|
74
|
+
},
|
|
71
75
|
"./sync": {
|
|
72
76
|
"types": "./dist/sync/index.d.ts",
|
|
73
77
|
"default": "./dist/sync/index.js"
|