@electric-ax/agents-server 0.3.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/LICENSE +177 -0
- package/dist/chunk-Cl8Af3a2.js +11 -0
- package/dist/entrypoint.js +7319 -0
- package/dist/index.cjs +7090 -0
- package/dist/index.d.cts +4262 -0
- package/dist/index.d.ts +4263 -0
- package/dist/index.js +7053 -0
- package/drizzle/0000_baseline.sql +97 -0
- package/drizzle/0001_entity_tags_and_bridges.sql +45 -0
- package/drizzle/0002_tag_outbox_hardening.sql +14 -0
- package/drizzle/0003_entity_manifest_sources.sql +11 -0
- package/drizzle/0004_tenant_scoping.sql +139 -0
- package/drizzle/0005_pull_wake_control_plane.sql +156 -0
- package/drizzle/meta/0000_snapshot.json +593 -0
- package/drizzle/meta/_journal.json +48 -0
- package/package.json +89 -0
- package/src/authenticated-user-format.ts +17 -0
- package/src/claim-write-token-store.ts +74 -0
- package/src/db/index.ts +53 -0
- package/src/db/schema.ts +490 -0
- package/src/dev-asserted-auth.ts +46 -0
- package/src/dispatch-policy-schema.ts +52 -0
- package/src/electric-agents/adapter-types.ts +70 -0
- package/src/electric-agents/default-entity-schemas.ts +1 -0
- package/src/electric-agents/schema-validator.ts +143 -0
- package/src/electric-agents-http.ts +46 -0
- package/src/electric-agents-types.ts +335 -0
- package/src/entity-bridge-manager.ts +694 -0
- package/src/entity-manager.ts +2601 -0
- package/src/entity-projector.ts +765 -0
- package/src/entity-registry.ts +1162 -0
- package/src/entrypoint-lib.ts +295 -0
- package/src/entrypoint.ts +11 -0
- package/src/host.ts +323 -0
- package/src/index.ts +49 -0
- package/src/manifest-side-effects.ts +183 -0
- package/src/routing/agent-ui-router.ts +81 -0
- package/src/routing/context.ts +35 -0
- package/src/routing/cron-router.ts +45 -0
- package/src/routing/dispatch-policy.ts +248 -0
- package/src/routing/durable-streams-router.ts +407 -0
- package/src/routing/durable-streams-routing-adapter.ts +96 -0
- package/src/routing/electric-proxy-router.ts +61 -0
- package/src/routing/entities-router.ts +484 -0
- package/src/routing/entity-types-router.ts +229 -0
- package/src/routing/global-router.ts +33 -0
- package/src/routing/hooks.ts +123 -0
- package/src/routing/internal-router.ts +741 -0
- package/src/routing/oss-server-router.ts +56 -0
- package/src/routing/runners-router.ts +416 -0
- package/src/routing/schema.ts +141 -0
- package/src/routing/stream-append.ts +196 -0
- package/src/routing/tenant-stream-paths.ts +26 -0
- package/src/runtime-registry.ts +49 -0
- package/src/runtime.ts +537 -0
- package/src/scheduler.ts +788 -0
- package/src/schema-validation.ts +15 -0
- package/src/server.ts +374 -0
- package/src/standalone-runtime.ts +188 -0
- package/src/stream-client.ts +842 -0
- package/src/tag-stream-outbox-drainer.ts +188 -0
- package/src/tenant.ts +25 -0
- package/src/tracing.ts +57 -0
- package/src/utils/electric-url.ts +15 -0
- package/src/utils/log.ts +95 -0
- package/src/utils/server-utils.ts +245 -0
- package/src/utils/webhook-url.ts +33 -0
- package/src/wake-registry.ts +946 -0
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { entityStateSchema } from '@electric-ax/agents-runtime'
|
|
3
|
+
import { serverLog } from './utils/log.js'
|
|
4
|
+
import { DEFAULT_TENANT_ID, isUnregisteredTenantError } from './tenant.js'
|
|
5
|
+
import type { ChangeEvent } from '@durable-streams/state'
|
|
6
|
+
import type { PostgresRegistry, TagStreamOutboxRow } from './entity-registry.js'
|
|
7
|
+
import type { StreamClient } from './stream-client.js'
|
|
8
|
+
|
|
9
|
+
const DRAIN_INTERVAL_MS = 500
|
|
10
|
+
const MAX_FAILURE_ATTEMPTS = 10
|
|
11
|
+
|
|
12
|
+
type StreamClientResolver = (
|
|
13
|
+
tenantId: string
|
|
14
|
+
) => StreamClient | Promise<StreamClient>
|
|
15
|
+
type TenantIdsProvider = () => Iterable<string>
|
|
16
|
+
|
|
17
|
+
interface TagStreamOutboxDrainerOptions {
|
|
18
|
+
tenantId?: string | null
|
|
19
|
+
tenantIds?: TenantIdsProvider
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class TagStreamOutboxDrainer {
|
|
23
|
+
private timer: NodeJS.Timeout | null = null
|
|
24
|
+
private draining = false
|
|
25
|
+
private activeDrain: Promise<void> | null = null
|
|
26
|
+
private stopping = false
|
|
27
|
+
private workerId = randomUUID()
|
|
28
|
+
private readonly streamClientForTenant: StreamClientResolver
|
|
29
|
+
private readonly tenantId: string | null
|
|
30
|
+
private readonly tenantIds?: TenantIdsProvider
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private registry: PostgresRegistry,
|
|
34
|
+
streamClient: StreamClient | StreamClientResolver,
|
|
35
|
+
options?: TagStreamOutboxDrainerOptions
|
|
36
|
+
) {
|
|
37
|
+
this.streamClientForTenant =
|
|
38
|
+
typeof streamClient === `function` ? streamClient : () => streamClient
|
|
39
|
+
this.tenantId =
|
|
40
|
+
options?.tenantId !== undefined
|
|
41
|
+
? options.tenantId
|
|
42
|
+
: (registry.tenantId ?? DEFAULT_TENANT_ID)
|
|
43
|
+
this.tenantIds = options?.tenantIds
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
start(): void {
|
|
47
|
+
if (this.timer) return
|
|
48
|
+
this.timer = setInterval(() => {
|
|
49
|
+
void this.runDrain().catch((error) => {
|
|
50
|
+
serverLog.warn(`[tag-outbox] drain failed:`, error)
|
|
51
|
+
})
|
|
52
|
+
}, DRAIN_INTERVAL_MS)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async stop(): Promise<void> {
|
|
56
|
+
this.stopping = true
|
|
57
|
+
if (this.timer) {
|
|
58
|
+
clearInterval(this.timer)
|
|
59
|
+
this.timer = null
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
await this.activeDrain
|
|
63
|
+
} finally {
|
|
64
|
+
await this.registry.releaseTagOutboxClaims(this.workerId, this.tenantId)
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async drainOnce(): Promise<void> {
|
|
69
|
+
await this.runDrain()
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
private async runDrain(): Promise<void> {
|
|
73
|
+
if (this.stopping) return
|
|
74
|
+
if (this.draining) return
|
|
75
|
+
this.draining = true
|
|
76
|
+
const drainPromise = (async () => {
|
|
77
|
+
const rows = await this.claimRows(25)
|
|
78
|
+
for (const row of rows) {
|
|
79
|
+
await this.publishRow(row).catch((error) => {
|
|
80
|
+
return this.handlePublishFailure(row, error)
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
})()
|
|
84
|
+
this.activeDrain = drainPromise
|
|
85
|
+
|
|
86
|
+
try {
|
|
87
|
+
await drainPromise
|
|
88
|
+
} finally {
|
|
89
|
+
this.activeDrain = null
|
|
90
|
+
this.draining = false
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private async claimRows(limit: number): Promise<Array<TagStreamOutboxRow>> {
|
|
95
|
+
const tenantIds = this.sharedTenantIds()
|
|
96
|
+
if (!tenantIds) {
|
|
97
|
+
return await this.registry.claimTagOutboxRows(
|
|
98
|
+
this.workerId,
|
|
99
|
+
limit,
|
|
100
|
+
this.tenantId
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
if (tenantIds.length === 0) return []
|
|
104
|
+
|
|
105
|
+
const rows: Array<TagStreamOutboxRow> = []
|
|
106
|
+
for (const tenantId of tenantIds) {
|
|
107
|
+
const remaining = limit - rows.length
|
|
108
|
+
if (remaining <= 0) break
|
|
109
|
+
rows.push(
|
|
110
|
+
...(await this.registry.claimTagOutboxRows(
|
|
111
|
+
this.workerId,
|
|
112
|
+
remaining,
|
|
113
|
+
tenantId
|
|
114
|
+
))
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
return rows
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Per-row producer identity (producerId = tag-outbox-${row.id}, epoch=0,
|
|
121
|
+
// seq=0) is deliberate: the protocol requires same-epoch seqs to be
|
|
122
|
+
// contiguous (PROTOCOL.md), so a shared per-entity producerId with
|
|
123
|
+
// seq = outbox.id would 409 the first time another entity's row
|
|
124
|
+
// interleaves. Each row is its own single-append producer; retries
|
|
125
|
+
// replay the same triple and dedupe server-side as 204.
|
|
126
|
+
private async publishRow(row: TagStreamOutboxRow): Promise<void> {
|
|
127
|
+
const event = buildTagChangeEvent(row)
|
|
128
|
+
const streamClient = await this.streamClientForTenant(row.tenantId)
|
|
129
|
+
await streamClient.appendWithProducerHeaders(
|
|
130
|
+
`${row.entityUrl}/main`,
|
|
131
|
+
JSON.stringify(event),
|
|
132
|
+
{
|
|
133
|
+
producerId: `tag-outbox-${row.id}`,
|
|
134
|
+
epoch: 0,
|
|
135
|
+
seq: 0,
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
await this.registry.deleteTagOutboxRow(row.id, row.tenantId)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async handlePublishFailure(
|
|
142
|
+
row: TagStreamOutboxRow,
|
|
143
|
+
error: unknown
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
146
|
+
if (isUnregisteredTenantError(error)) {
|
|
147
|
+
await this.registry.releaseTagOutboxClaims(this.workerId, row.tenantId)
|
|
148
|
+
serverLog.warn(
|
|
149
|
+
`[tag-outbox] skipped row ${row.id} for unregistered tenant "${row.tenantId}": ${message}`
|
|
150
|
+
)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const result = await this.registry.failTagOutboxRow(
|
|
155
|
+
row.id,
|
|
156
|
+
this.workerId,
|
|
157
|
+
message,
|
|
158
|
+
MAX_FAILURE_ATTEMPTS,
|
|
159
|
+
row.tenantId
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
const logLine = `[tag-outbox] row ${row.id} failed (attempt ${result.attemptCount}/${MAX_FAILURE_ATTEMPTS})`
|
|
163
|
+
if (result.deadLettered) {
|
|
164
|
+
serverLog.error(`${logLine}; dead-lettered: ${message}`)
|
|
165
|
+
return
|
|
166
|
+
}
|
|
167
|
+
serverLog.warn(`${logLine}: ${message}`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private sharedTenantIds(): Array<string> | null {
|
|
171
|
+
if (this.tenantId !== null || !this.tenantIds) return null
|
|
172
|
+
return [...new Set(this.tenantIds())]
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function buildTagChangeEvent(row: TagStreamOutboxRow): ChangeEvent<unknown> {
|
|
177
|
+
const headers = { timestamp: new Date().toISOString() }
|
|
178
|
+
|
|
179
|
+
if (row.op === `delete`) {
|
|
180
|
+
return entityStateSchema.tags.delete({ key: row.key, headers })
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const value = row.rowData ?? { key: row.key, value: `` }
|
|
184
|
+
if (row.op === `insert`) {
|
|
185
|
+
return entityStateSchema.tags.insert({ key: row.key, value, headers })
|
|
186
|
+
}
|
|
187
|
+
return entityStateSchema.tags.update({ key: row.key, value, headers })
|
|
188
|
+
}
|
package/src/tenant.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export const DEFAULT_TENANT_ID = `default`
|
|
2
|
+
|
|
3
|
+
export class UnregisteredTenantError extends Error {
|
|
4
|
+
constructor(
|
|
5
|
+
readonly tenantId: string,
|
|
6
|
+
readonly processName: string
|
|
7
|
+
) {
|
|
8
|
+
super(
|
|
9
|
+
`tenant "${tenantId}" is not registered on this host for ${processName}`
|
|
10
|
+
)
|
|
11
|
+
this.name = `UnregisteredTenantError`
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isUnregisteredTenantError(
|
|
16
|
+
error: unknown
|
|
17
|
+
): error is UnregisteredTenantError {
|
|
18
|
+
return (
|
|
19
|
+
error instanceof UnregisteredTenantError ||
|
|
20
|
+
(typeof error === `object` &&
|
|
21
|
+
error !== null &&
|
|
22
|
+
`name` in error &&
|
|
23
|
+
(error as { name?: unknown }).name === `UnregisteredTenantError`)
|
|
24
|
+
)
|
|
25
|
+
}
|
package/src/tracing.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { SpanStatusCode, context, propagation, trace } from '@opentelemetry/api'
|
|
2
|
+
import type { Context, Span, SpanOptions, Tracer } from '@opentelemetry/api'
|
|
3
|
+
|
|
4
|
+
export const tracer: Tracer = trace.getTracer(`agent-server`)
|
|
5
|
+
|
|
6
|
+
export const ATTR = {
|
|
7
|
+
ENTITY_URL: `electric_agents.entity.url`,
|
|
8
|
+
ENTITY_TYPE: `electric_agents.entity.type`,
|
|
9
|
+
PARENT_URL: `electric_agents.entity.parent`,
|
|
10
|
+
WAKE_SOURCE: `electric_agents.wake.source`,
|
|
11
|
+
WAKE_SUBSCRIBER: `electric_agents.wake.subscriber`,
|
|
12
|
+
WAKE_KIND: `electric_agents.wake.kind`,
|
|
13
|
+
STREAM_PATH: `electric_agents.stream.path`,
|
|
14
|
+
STREAM_OP: `electric_agents.stream.op`,
|
|
15
|
+
DB_OP: `electric_agents.db.op`,
|
|
16
|
+
HTTP_METHOD: `http.method`,
|
|
17
|
+
HTTP_ROUTE: `http.route`,
|
|
18
|
+
HTTP_STATUS: `http.status_code`,
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Run `fn` inside an active span. Errors are recorded + status set to ERROR,
|
|
23
|
+
* then re-thrown. Span ends in a finally block.
|
|
24
|
+
*/
|
|
25
|
+
export async function withSpan<T>(
|
|
26
|
+
name: string,
|
|
27
|
+
fn: (span: Span) => Promise<T>,
|
|
28
|
+
opts?: SpanOptions
|
|
29
|
+
): Promise<T> {
|
|
30
|
+
return await tracer.startActiveSpan(name, opts ?? {}, async (span) => {
|
|
31
|
+
try {
|
|
32
|
+
return await fn(span)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
span.recordException(err as Error)
|
|
35
|
+
span.setStatus({
|
|
36
|
+
code: SpanStatusCode.ERROR,
|
|
37
|
+
message: err instanceof Error ? err.message : String(err),
|
|
38
|
+
})
|
|
39
|
+
throw err
|
|
40
|
+
} finally {
|
|
41
|
+
span.end()
|
|
42
|
+
}
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function injectTraceHeaders(
|
|
47
|
+
headers: Record<string, string>,
|
|
48
|
+
ctx: Context = context.active()
|
|
49
|
+
): void {
|
|
50
|
+
propagation.inject(ctx, headers)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function extractTraceContext(
|
|
54
|
+
headers: Record<string, string | Array<string> | undefined>
|
|
55
|
+
): Context {
|
|
56
|
+
return propagation.extract(context.active(), headers)
|
|
57
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export function applyElectricUrlQueryParams(
|
|
2
|
+
target: URL,
|
|
3
|
+
electricUrl: string
|
|
4
|
+
): void {
|
|
5
|
+
const configured = new URL(electricUrl)
|
|
6
|
+
configured.searchParams.forEach((value, key) => {
|
|
7
|
+
target.searchParams.set(key, value)
|
|
8
|
+
})
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function electricUrlWithPath(electricUrl: string, path: string): URL {
|
|
12
|
+
const target = new URL(path, electricUrl)
|
|
13
|
+
applyElectricUrlQueryParams(target, electricUrl)
|
|
14
|
+
return target
|
|
15
|
+
}
|
package/src/utils/log.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import fs from 'node:fs'
|
|
3
|
+
import pino from 'pino'
|
|
4
|
+
|
|
5
|
+
const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`
|
|
6
|
+
const IS_ELECTRON_MAIN = Boolean(process.versions.electron)
|
|
7
|
+
const USE_FILE_LOGS = process.env.ELECTRIC_AGENTS_LOG_FILE !== `false`
|
|
8
|
+
const USE_PRETTY_LOGS =
|
|
9
|
+
LOG_LEVEL !== `silent` && !process.env.VITEST && !IS_ELECTRON_MAIN
|
|
10
|
+
|
|
11
|
+
const LOG_DIR = USE_FILE_LOGS
|
|
12
|
+
? (process.env.ELECTRIC_AGENTS_LOG_DIR ?? path.resolve(process.cwd(), `logs`))
|
|
13
|
+
: undefined
|
|
14
|
+
const LOG_FILE = LOG_DIR
|
|
15
|
+
? path.join(LOG_DIR, `agent-server-${Date.now()}.jsonl`)
|
|
16
|
+
: undefined
|
|
17
|
+
|
|
18
|
+
if (LOG_DIR) {
|
|
19
|
+
fs.mkdirSync(LOG_DIR, { recursive: true })
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const LOG_FILE_PATH = LOG_FILE
|
|
23
|
+
|
|
24
|
+
const streams: Array<pino.StreamEntry> = []
|
|
25
|
+
if (LOG_FILE) {
|
|
26
|
+
streams.push({
|
|
27
|
+
stream: pino.destination({
|
|
28
|
+
dest: LOG_FILE,
|
|
29
|
+
sync: IS_ELECTRON_MAIN,
|
|
30
|
+
}),
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
if (USE_PRETTY_LOGS) {
|
|
34
|
+
streams.push({
|
|
35
|
+
stream: pino.transport({
|
|
36
|
+
target: `pino-pretty`,
|
|
37
|
+
options: {
|
|
38
|
+
colorize: true,
|
|
39
|
+
ignore: `pid,hostname,name`,
|
|
40
|
+
translateTime: `SYS:HH:MM:ss`,
|
|
41
|
+
},
|
|
42
|
+
}),
|
|
43
|
+
})
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const logger =
|
|
47
|
+
streams.length > 0
|
|
48
|
+
? pino(
|
|
49
|
+
{
|
|
50
|
+
base: undefined,
|
|
51
|
+
level: LOG_LEVEL,
|
|
52
|
+
},
|
|
53
|
+
pino.multistream(streams)
|
|
54
|
+
)
|
|
55
|
+
: pino({
|
|
56
|
+
base: undefined,
|
|
57
|
+
enabled: false,
|
|
58
|
+
level: LOG_LEVEL,
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
function formatArgs(args: Array<unknown>): { err?: Error; msg: string } {
|
|
62
|
+
const errors: Array<Error> = []
|
|
63
|
+
const parts: Array<string> = []
|
|
64
|
+
for (const a of args) {
|
|
65
|
+
if (a instanceof Error) errors.push(a)
|
|
66
|
+
else parts.push(typeof a === `string` ? a : JSON.stringify(a))
|
|
67
|
+
}
|
|
68
|
+
return {
|
|
69
|
+
err: errors[0],
|
|
70
|
+
msg: parts.join(` `),
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const serverLog = {
|
|
75
|
+
info(...args: Array<unknown>): void {
|
|
76
|
+
const { msg } = formatArgs(args)
|
|
77
|
+
logger.info(msg)
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
warn(...args: Array<unknown>): void {
|
|
81
|
+
const { err, msg } = formatArgs(args)
|
|
82
|
+
if (err) logger.warn({ err }, msg)
|
|
83
|
+
else logger.warn(msg)
|
|
84
|
+
},
|
|
85
|
+
|
|
86
|
+
error(...args: Array<unknown>): void {
|
|
87
|
+
const { err, msg } = formatArgs(args)
|
|
88
|
+
if (err) logger.error({ err }, msg)
|
|
89
|
+
else logger.error(msg)
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
event(obj: Record<string, unknown>, msg: string): void {
|
|
93
|
+
logger.info(obj, msg)
|
|
94
|
+
},
|
|
95
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { access } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
applyElectricUrlQueryParams,
|
|
5
|
+
electricUrlWithPath,
|
|
6
|
+
} from './electric-url.js'
|
|
7
|
+
import { resolveDurableStreamsRoutingAdapter } from '../routing/durable-streams-routing-adapter.js'
|
|
8
|
+
import { applyDurableStreamsBearer } from '../stream-client.js'
|
|
9
|
+
import type { Agent } from 'undici'
|
|
10
|
+
import type { DurableStreamsRoutingAdapter } from '../routing/durable-streams-routing-adapter.js'
|
|
11
|
+
import type { DurableStreamsBearerProvider } from '../stream-client.js'
|
|
12
|
+
|
|
13
|
+
export function contentTypeForStaticFile(filePath: string): string {
|
|
14
|
+
const ext = path.extname(filePath).toLowerCase()
|
|
15
|
+
switch (ext) {
|
|
16
|
+
case `.html`:
|
|
17
|
+
return `text/html; charset=utf-8`
|
|
18
|
+
case `.js`:
|
|
19
|
+
return `text/javascript; charset=utf-8`
|
|
20
|
+
case `.css`:
|
|
21
|
+
return `text/css; charset=utf-8`
|
|
22
|
+
case `.json`:
|
|
23
|
+
return `application/json; charset=utf-8`
|
|
24
|
+
case `.svg`:
|
|
25
|
+
return `image/svg+xml`
|
|
26
|
+
case `.png`:
|
|
27
|
+
return `image/png`
|
|
28
|
+
case `.jpg`:
|
|
29
|
+
case `.jpeg`:
|
|
30
|
+
return `image/jpeg`
|
|
31
|
+
case `.ico`:
|
|
32
|
+
return `image/x-icon`
|
|
33
|
+
case `.map`:
|
|
34
|
+
return `application/json; charset=utf-8`
|
|
35
|
+
default:
|
|
36
|
+
return `application/octet-stream`
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function cacheControlForAgentUiFile(filePath: string): string {
|
|
41
|
+
return filePath.includes(`${path.sep}assets${path.sep}`)
|
|
42
|
+
? `public, max-age=31536000, immutable`
|
|
43
|
+
: `no-cache`
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function resolveAgentUiPath(
|
|
47
|
+
agentUiDistDir: string,
|
|
48
|
+
relativePath: string
|
|
49
|
+
): string {
|
|
50
|
+
const normalized = relativePath.replace(/^\/+/, ``)
|
|
51
|
+
return path.resolve(agentUiDistDir, normalized)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function pickAgentUiFile(
|
|
55
|
+
agentUiDistDir: string,
|
|
56
|
+
filePath: string,
|
|
57
|
+
fallbackToIndex: boolean
|
|
58
|
+
): Promise<string | null> {
|
|
59
|
+
if (isAgentUiPath(agentUiDistDir, filePath) && (await fileExists(filePath))) {
|
|
60
|
+
return filePath
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (!fallbackToIndex) {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const indexPath = path.join(agentUiDistDir, `index.html`)
|
|
68
|
+
if (!(await fileExists(indexPath))) {
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
return indexPath
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isAgentUiPath(
|
|
75
|
+
agentUiDistDir: string,
|
|
76
|
+
filePath: string
|
|
77
|
+
): boolean {
|
|
78
|
+
return (
|
|
79
|
+
filePath === agentUiDistDir ||
|
|
80
|
+
filePath.startsWith(`${agentUiDistDir}${path.sep}`)
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export async function fileExists(filePath: string): Promise<boolean> {
|
|
85
|
+
try {
|
|
86
|
+
await access(filePath)
|
|
87
|
+
return true
|
|
88
|
+
} catch {
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildElectricProxyTarget(options: {
|
|
94
|
+
incomingUrl: URL
|
|
95
|
+
electricUrl: string
|
|
96
|
+
electricSecret?: string
|
|
97
|
+
tenantId: string
|
|
98
|
+
}): URL {
|
|
99
|
+
const targetPath = options.incomingUrl.pathname.replace(
|
|
100
|
+
`/_electric/electric`,
|
|
101
|
+
``
|
|
102
|
+
)
|
|
103
|
+
const target = electricUrlWithPath(options.electricUrl, targetPath)
|
|
104
|
+
options.incomingUrl.searchParams.forEach((value, key) => {
|
|
105
|
+
target.searchParams.append(key, value)
|
|
106
|
+
})
|
|
107
|
+
applyElectricUrlQueryParams(target, options.electricUrl)
|
|
108
|
+
|
|
109
|
+
if (targetPath !== `/v1/shape`) {
|
|
110
|
+
return target
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (options.electricSecret) {
|
|
114
|
+
target.searchParams.set(`secret`, options.electricSecret)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const table = options.incomingUrl.searchParams.get(`table`)
|
|
118
|
+
if (table === `entities`) {
|
|
119
|
+
target.searchParams.set(
|
|
120
|
+
`columns`,
|
|
121
|
+
`"tenant_id","url","type","status","dispatch_policy","tags","spawn_args","parent","type_revision","inbox_schemas","state_schemas","created_at","updated_at"`
|
|
122
|
+
)
|
|
123
|
+
applyTenantShapeWhere(target, options.tenantId)
|
|
124
|
+
} else if (table === `entity_types`) {
|
|
125
|
+
target.searchParams.set(
|
|
126
|
+
`columns`,
|
|
127
|
+
`"tenant_id","name","description","creation_schema","inbox_schemas","state_schemas","serve_endpoint","default_dispatch_policy","revision","created_at","updated_at"`
|
|
128
|
+
)
|
|
129
|
+
applyTenantShapeWhere(target, options.tenantId)
|
|
130
|
+
} else if (table === `runners`) {
|
|
131
|
+
target.searchParams.set(
|
|
132
|
+
`columns`,
|
|
133
|
+
`"tenant_id","id","owner_user_id","label","kind","admin_status","wake_stream","wake_stream_offset","last_seen_at","liveness_lease_expires_at","created_at","updated_at"`
|
|
134
|
+
)
|
|
135
|
+
applyTenantShapeWhere(target, options.tenantId)
|
|
136
|
+
} else if (table === `entity_dispatch_state`) {
|
|
137
|
+
target.searchParams.set(
|
|
138
|
+
`columns`,
|
|
139
|
+
`"tenant_id","entity_url","pending_source_streams","pending_reason","pending_since","outstanding_wake_id","outstanding_wake_target","outstanding_wake_created_at","active_consumer_id","active_runner_id","active_epoch","active_claimed_at","active_lease_expires_at","last_wake_id","last_claimed_at","last_released_at","last_completed_at","last_error","updated_at"`
|
|
140
|
+
)
|
|
141
|
+
applyTenantShapeWhere(target, options.tenantId)
|
|
142
|
+
} else if (table === `wake_notifications`) {
|
|
143
|
+
target.searchParams.set(
|
|
144
|
+
`columns`,
|
|
145
|
+
`"tenant_id","wake_id","entity_url","target_type","target_runner_id","target_webhook_url","target_worker_pool_id","runner_wake_stream","runner_wake_stream_offset","notification_public","delivery_status","claim_status","created_at","delivered_at","claimed_at","resolved_at"`
|
|
146
|
+
)
|
|
147
|
+
applyTenantShapeWhere(target, options.tenantId)
|
|
148
|
+
} else if (table === `consumer_claims`) {
|
|
149
|
+
target.searchParams.set(
|
|
150
|
+
`columns`,
|
|
151
|
+
`"tenant_id","consumer_id","epoch","wake_id","entity_url","stream_path","runner_id","status","claimed_at","last_heartbeat_at","lease_expires_at","released_at","acked_streams","updated_at"`
|
|
152
|
+
)
|
|
153
|
+
applyTenantShapeWhere(target, options.tenantId)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return target
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export async function forwardFetchRequest(options: {
|
|
160
|
+
request: {
|
|
161
|
+
method: string
|
|
162
|
+
url: string
|
|
163
|
+
headers: Headers
|
|
164
|
+
}
|
|
165
|
+
durableStreamsUrl: string
|
|
166
|
+
durableStreamsRouting?: DurableStreamsRoutingAdapter
|
|
167
|
+
serviceId: string
|
|
168
|
+
body?: Uint8Array
|
|
169
|
+
dispatcher?: Agent
|
|
170
|
+
route?: `stream` | `stream-meta`
|
|
171
|
+
durableStreamsBearer?: DurableStreamsBearerProvider
|
|
172
|
+
durableStreamsBearerMode?: `overwrite` | `if-missing` | `none`
|
|
173
|
+
}): Promise<Response> {
|
|
174
|
+
const routingAdapter = resolveDurableStreamsRoutingAdapter(
|
|
175
|
+
options.durableStreamsRouting
|
|
176
|
+
)
|
|
177
|
+
const routingInput = {
|
|
178
|
+
durableStreamsUrl: options.durableStreamsUrl,
|
|
179
|
+
serviceId: options.serviceId,
|
|
180
|
+
requestUrl: options.request.url,
|
|
181
|
+
}
|
|
182
|
+
const upstreamUrl =
|
|
183
|
+
options.route === `stream-meta`
|
|
184
|
+
? routingAdapter.streamMetaUrl(routingInput)
|
|
185
|
+
: routingAdapter.streamUrl(routingInput)
|
|
186
|
+
|
|
187
|
+
const headers = new Headers(options.request.headers)
|
|
188
|
+
if (options.durableStreamsBearerMode !== `none`) {
|
|
189
|
+
await applyDurableStreamsBearer(headers, options.durableStreamsBearer, {
|
|
190
|
+
overwrite: options.durableStreamsBearerMode !== `if-missing`,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const init: RequestInit & { duplex?: `half`; dispatcher?: Agent } = {
|
|
195
|
+
method: options.request.method,
|
|
196
|
+
headers,
|
|
197
|
+
}
|
|
198
|
+
if (options.body !== undefined) {
|
|
199
|
+
headers.delete(`content-length`)
|
|
200
|
+
init.body = bodyFromBytes(options.body)
|
|
201
|
+
init.duplex = `half`
|
|
202
|
+
}
|
|
203
|
+
if (options.dispatcher) {
|
|
204
|
+
init.dispatcher = options.dispatcher
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return await fetch(upstreamUrl, init as RequestInit)
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function bodyFromBytes(body: Uint8Array): ArrayBuffer {
|
|
211
|
+
return body.buffer.slice(
|
|
212
|
+
body.byteOffset,
|
|
213
|
+
body.byteOffset + body.byteLength
|
|
214
|
+
) as ArrayBuffer
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function decodeJsonObject(
|
|
218
|
+
body: Uint8Array
|
|
219
|
+
): Record<string, unknown> | null {
|
|
220
|
+
if (body.length === 0) return null
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
const parsed = JSON.parse(new TextDecoder().decode(body)) as unknown
|
|
224
|
+
if (parsed && typeof parsed === `object` && !Array.isArray(parsed)) {
|
|
225
|
+
return parsed as Record<string, unknown>
|
|
226
|
+
}
|
|
227
|
+
} catch {
|
|
228
|
+
// Not JSON; callers can fall back to raw bytes.
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return null
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function applyTenantShapeWhere(target: URL, tenantId: string): void {
|
|
235
|
+
const tenantWhere = `tenant_id = ${sqlStringLiteral(tenantId)}`
|
|
236
|
+
const existingWhere = target.searchParams.get(`where`)
|
|
237
|
+
target.searchParams.set(
|
|
238
|
+
`where`,
|
|
239
|
+
existingWhere ? `${tenantWhere} AND (${existingWhere})` : tenantWhere
|
|
240
|
+
)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function sqlStringLiteral(value: string): string {
|
|
244
|
+
return `'${value.replace(/'/g, `''`)}'`
|
|
245
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export function rewriteLoopbackWebhookUrl(
|
|
2
|
+
value: string | undefined
|
|
3
|
+
): string | undefined {
|
|
4
|
+
if (!value) return undefined
|
|
5
|
+
|
|
6
|
+
const rewriteTarget =
|
|
7
|
+
process.env.ELECTRIC_AGENTS_REWRITE_LOOPBACK_WEBHOOKS_TO?.trim()
|
|
8
|
+
if (!rewriteTarget) return value
|
|
9
|
+
|
|
10
|
+
const url = new URL(value)
|
|
11
|
+
if (!isLoopbackHostname(url.hostname)) {
|
|
12
|
+
return value
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
if (rewriteTarget.includes(`://`)) {
|
|
16
|
+
const target = new URL(rewriteTarget)
|
|
17
|
+
url.protocol = target.protocol
|
|
18
|
+
url.username = target.username
|
|
19
|
+
url.password = target.password
|
|
20
|
+
url.hostname = target.hostname
|
|
21
|
+
url.port = target.port
|
|
22
|
+
return url.toString()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
url.host = rewriteTarget
|
|
26
|
+
return url.toString()
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function isLoopbackHostname(hostname: string): boolean {
|
|
30
|
+
return (
|
|
31
|
+
hostname === `localhost` || hostname === `127.0.0.1` || hostname === `::1`
|
|
32
|
+
)
|
|
33
|
+
}
|