typeclaw 0.3.1 → 0.4.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 +20 -15
- package/auth.schema.json +113 -0
- package/package.json +1 -1
- package/secrets.schema.json +113 -0
- package/src/agent/session-meta.ts +1 -1
- package/src/agent/session-origin.ts +3 -2
- package/src/bundled-plugins/security/index.ts +3 -2
- package/src/channels/adapters/github/auth-app.ts +120 -0
- package/src/channels/adapters/github/auth-pat.ts +50 -0
- package/src/channels/adapters/github/auth.ts +33 -0
- package/src/channels/adapters/github/channel-resolver.ts +30 -0
- package/src/channels/adapters/github/dedup.ts +26 -0
- package/src/channels/adapters/github/event-allowlist.ts +8 -0
- package/src/channels/adapters/github/fetch-attachment.ts +5 -0
- package/src/channels/adapters/github/history.ts +63 -0
- package/src/channels/adapters/github/inbound.ts +286 -0
- package/src/channels/adapters/github/index.ts +286 -0
- package/src/channels/adapters/github/managed-path.ts +54 -0
- package/src/channels/adapters/github/membership.ts +35 -0
- package/src/channels/adapters/github/outbound.ts +145 -0
- package/src/channels/adapters/github/webhook-register.ts +349 -0
- package/src/channels/manager.ts +94 -9
- package/src/channels/schema.ts +31 -1
- package/src/channels/tunnel-bridge.ts +51 -0
- package/src/cli/builtins.ts +28 -0
- package/src/cli/channel.ts +511 -25
- package/src/cli/container-command-client.ts +244 -0
- package/src/cli/cron.ts +173 -0
- package/src/cli/host-command-runner.ts +150 -0
- package/src/cli/index.ts +42 -1
- package/src/cli/init.ts +256 -27
- package/src/cli/model.ts +4 -2
- package/src/cli/plugin-command-help.ts +49 -0
- package/src/cli/plugin-commands-dispatch.ts +112 -0
- package/src/cli/plugin-commands.ts +118 -0
- package/src/cli/tui.ts +10 -2
- package/src/cli/tunnel.ts +533 -0
- package/src/cli/ui.ts +8 -3
- package/src/config/config.ts +75 -0
- package/src/container/start.ts +30 -3
- package/src/cron/bridge.ts +136 -0
- package/src/cron/consumer.ts +45 -5
- package/src/cron/index.ts +19 -2
- package/src/cron/list.ts +105 -0
- package/src/cron/scheduler.ts +12 -3
- package/src/cron/schema.ts +11 -3
- package/src/doctor/checks.ts +0 -50
- package/src/init/dockerfile.ts +59 -13
- package/src/init/ensure-deps.ts +15 -4
- package/src/init/github-webhook-install.ts +109 -0
- package/src/init/index.ts +505 -9
- package/src/init/run-bun-install.ts +17 -3
- package/src/init/run-owner-claim.ts +11 -2
- package/src/permissions/builtins.ts +6 -1
- package/src/permissions/match-rule.ts +24 -2
- package/src/permissions/resolve.ts +1 -0
- package/src/plugin/define.ts +42 -1
- package/src/plugin/index.ts +18 -3
- package/src/plugin/manager.ts +2 -0
- package/src/plugin/registry.ts +85 -3
- package/src/plugin/types.ts +138 -1
- package/src/plugin/zod-introspect.ts +100 -0
- package/src/role-claim/match-rule.ts +2 -1
- package/src/run/index.ts +110 -3
- package/src/secrets/index.ts +1 -1
- package/src/secrets/schema.ts +21 -0
- package/src/server/command-runner.ts +476 -0
- package/src/server/index.ts +388 -5
- package/src/shared/index.ts +8 -0
- package/src/shared/protocol.ts +80 -1
- package/src/skills/typeclaw-channel-github/SKILL.md +24 -0
- package/src/skills/typeclaw-config/SKILL.md +27 -26
- package/src/skills/typeclaw-cron/SKILL.md +234 -3
- package/src/skills/typeclaw-monorepo/SKILL.md +2 -2
- package/src/skills/typeclaw-permissions/SKILL.md +5 -4
- package/src/skills/typeclaw-plugins/SKILL.md +251 -5
- package/src/skills/typeclaw-tunnels/SKILL.md +111 -0
- package/src/test-helpers/wait-for.ts +50 -0
- package/src/tui/index.ts +35 -4
- package/src/tunnels/__fixtures__/cloudflared-quick-stderr.txt +11 -0
- package/src/tunnels/events.ts +14 -0
- package/src/tunnels/index.ts +12 -0
- package/src/tunnels/log-ring.ts +54 -0
- package/src/tunnels/manager.ts +139 -0
- package/src/tunnels/providers/cloudflare-quick.ts +189 -0
- package/src/tunnels/providers/external.ts +53 -0
- package/src/tunnels/quick-url-parser.ts +5 -0
- package/src/tunnels/types.ts +43 -0
- package/typeclaw.schema.json +254 -1
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { Unsubscribe } from '@/stream'
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_LOG_RING_MAX_BYTES = 1024 * 1024
|
|
4
|
+
|
|
5
|
+
export type LogLineSubscriber = (line: string) => void
|
|
6
|
+
|
|
7
|
+
export type LogRingOptions = {
|
|
8
|
+
maxBytes?: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export type LogRing = {
|
|
12
|
+
append: (line: string) => void
|
|
13
|
+
snapshot: () => string[]
|
|
14
|
+
subscribe: (cb: LogLineSubscriber) => Unsubscribe
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const encoder = new TextEncoder()
|
|
18
|
+
|
|
19
|
+
export function createLogRing(options: LogRingOptions = {}): LogRing {
|
|
20
|
+
const maxBytes = options.maxBytes ?? DEFAULT_LOG_RING_MAX_BYTES
|
|
21
|
+
if (!Number.isInteger(maxBytes) || maxBytes < 1) {
|
|
22
|
+
throw new Error('LogRing maxBytes must be a positive integer')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lines: string[] = []
|
|
26
|
+
const sizes: number[] = []
|
|
27
|
+
const subscribers = new Set<LogLineSubscriber>()
|
|
28
|
+
let bytes = 0
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
append(line: string): void {
|
|
32
|
+
const size = encoder.encode(line).byteLength
|
|
33
|
+
lines.push(line)
|
|
34
|
+
sizes.push(size)
|
|
35
|
+
bytes += size
|
|
36
|
+
|
|
37
|
+
while (bytes > maxBytes && lines.length > 1) {
|
|
38
|
+
lines.shift()
|
|
39
|
+
bytes -= sizes.shift() ?? 0
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
for (const subscriber of subscribers) subscriber(line)
|
|
43
|
+
},
|
|
44
|
+
snapshot(): string[] {
|
|
45
|
+
return [...lines]
|
|
46
|
+
},
|
|
47
|
+
subscribe(cb: LogLineSubscriber): Unsubscribe {
|
|
48
|
+
subscribers.add(cb)
|
|
49
|
+
return () => {
|
|
50
|
+
subscribers.delete(cb)
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { Stream } from '@/stream'
|
|
2
|
+
|
|
3
|
+
import { createCloudflareQuickProvider } from './providers/cloudflare-quick'
|
|
4
|
+
import { createExternalProvider } from './providers/external'
|
|
5
|
+
import type { TunnelConfig, TunnelProviderHandle, TunnelState, TunnelUrlChangedPayload } from './types'
|
|
6
|
+
|
|
7
|
+
export type TunnelManagerLogger = {
|
|
8
|
+
info: (m: string) => void
|
|
9
|
+
warn: (m: string) => void
|
|
10
|
+
error: (m: string) => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type TunnelManagerOptions = {
|
|
14
|
+
tunnels: TunnelConfig[]
|
|
15
|
+
stream: Stream
|
|
16
|
+
resolveChannelUpstreamPort?: (channelName: string) => number | null
|
|
17
|
+
cloudflareQuickBinary?: string
|
|
18
|
+
logger?: TunnelManagerLogger
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type TunnelManager = {
|
|
22
|
+
start: () => Promise<void>
|
|
23
|
+
stop: () => Promise<void>
|
|
24
|
+
snapshot: () => TunnelState[]
|
|
25
|
+
urlFor: (tunnelName: string) => string | null
|
|
26
|
+
tail: (tunnelName: string) => string[]
|
|
27
|
+
subscribeToLogs: (tunnelName: string, cb: (line: string) => void) => () => void
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const consoleLogger: TunnelManagerLogger = {
|
|
31
|
+
info: (m) => console.log(m),
|
|
32
|
+
warn: (m) => console.warn(m),
|
|
33
|
+
error: (m) => console.error(m),
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function createTunnelManager(options: TunnelManagerOptions): TunnelManager {
|
|
37
|
+
const logger = options.logger ?? consoleLogger
|
|
38
|
+
const handles = new Map<string, TunnelProviderHandle>()
|
|
39
|
+
|
|
40
|
+
for (const config of options.tunnels) {
|
|
41
|
+
const handle = buildProvider(
|
|
42
|
+
config,
|
|
43
|
+
options.resolveChannelUpstreamPort,
|
|
44
|
+
(url) => publishUrlChange(options.stream, config, url, logger),
|
|
45
|
+
options.cloudflareQuickBinary,
|
|
46
|
+
)
|
|
47
|
+
handles.set(config.name, handle)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
async start(): Promise<void> {
|
|
52
|
+
await Promise.all(
|
|
53
|
+
Array.from(handles.values()).map(async (h) => {
|
|
54
|
+
try {
|
|
55
|
+
await h.start()
|
|
56
|
+
} catch (err) {
|
|
57
|
+
logger.error(
|
|
58
|
+
`[tunnels] ${h.snapshot().name}: start failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}),
|
|
62
|
+
)
|
|
63
|
+
},
|
|
64
|
+
async stop(): Promise<void> {
|
|
65
|
+
await Promise.all(
|
|
66
|
+
Array.from(handles.values()).map((h) =>
|
|
67
|
+
h.stop().catch((err: unknown) => {
|
|
68
|
+
logger.warn(
|
|
69
|
+
`[tunnels] ${h.snapshot().name}: stop failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
70
|
+
)
|
|
71
|
+
}),
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
},
|
|
75
|
+
snapshot(): TunnelState[] {
|
|
76
|
+
return Array.from(handles.values()).map((h) => h.snapshot())
|
|
77
|
+
},
|
|
78
|
+
urlFor(tunnelName: string): string | null {
|
|
79
|
+
return handles.get(tunnelName)?.snapshot().url ?? null
|
|
80
|
+
},
|
|
81
|
+
tail(tunnelName: string): string[] {
|
|
82
|
+
return handles.get(tunnelName)?.tail() ?? []
|
|
83
|
+
},
|
|
84
|
+
subscribeToLogs(tunnelName: string, cb: (line: string) => void): () => void {
|
|
85
|
+
return handles.get(tunnelName)?.subscribeToLogs(cb) ?? (() => {})
|
|
86
|
+
},
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildProvider(
|
|
91
|
+
config: TunnelConfig,
|
|
92
|
+
resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
|
|
93
|
+
onUrlChange: (url: string) => void,
|
|
94
|
+
cloudflareQuickBinary: string | undefined,
|
|
95
|
+
): TunnelProviderHandle {
|
|
96
|
+
switch (config.provider) {
|
|
97
|
+
case 'external':
|
|
98
|
+
return createExternalProvider({ config, onUrlChange })
|
|
99
|
+
case 'cloudflare-quick':
|
|
100
|
+
return createCloudflareQuickProvider({
|
|
101
|
+
config,
|
|
102
|
+
upstreamPort: resolveUpstreamPort(config, resolveChannelUpstreamPort),
|
|
103
|
+
onUrlChange,
|
|
104
|
+
binary: cloudflareQuickBinary,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function resolveUpstreamPort(
|
|
110
|
+
config: TunnelConfig,
|
|
111
|
+
resolveChannelUpstreamPort: TunnelManagerOptions['resolveChannelUpstreamPort'],
|
|
112
|
+
): number {
|
|
113
|
+
if (config.for.kind === 'manual') {
|
|
114
|
+
if (config.upstreamPort === undefined) {
|
|
115
|
+
throw new Error(`tunnel '${config.name}' (cloudflare-quick): upstreamPort is required for manual tunnels`)
|
|
116
|
+
}
|
|
117
|
+
return config.upstreamPort
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const upstreamPort = resolveChannelUpstreamPort?.(config.for.name) ?? null
|
|
121
|
+
if (upstreamPort === null) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`tunnel '${config.name}' (cloudflare-quick): no upstream port resolved for channel '${config.for.name}'`,
|
|
124
|
+
)
|
|
125
|
+
}
|
|
126
|
+
return upstreamPort
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function publishUrlChange(stream: Stream, config: TunnelConfig, url: string, logger: TunnelManagerLogger): void {
|
|
130
|
+
const payload: TunnelUrlChangedPayload = {
|
|
131
|
+
kind: 'tunnel-url-changed',
|
|
132
|
+
tunnelName: config.name,
|
|
133
|
+
url,
|
|
134
|
+
for: config.for,
|
|
135
|
+
rotatedAt: new Date().toISOString(),
|
|
136
|
+
}
|
|
137
|
+
stream.publish({ target: { kind: 'broadcast' }, payload })
|
|
138
|
+
logger.info(`[tunnels] ${config.name}: URL set to ${url}`)
|
|
139
|
+
}
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Unsubscribe } from '@/stream'
|
|
2
|
+
|
|
3
|
+
import { createLogRing, type LogLineSubscriber, type LogRing } from '../log-ring'
|
|
4
|
+
import { extractQuickTunnelUrl } from '../quick-url-parser'
|
|
5
|
+
import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BINARY = 'cloudflared'
|
|
8
|
+
const DEFAULT_RESTART_BACKOFF_MS = [1_000, 2_000, 4_000, 10_000, 30_000]
|
|
9
|
+
const DEFAULT_MAX_FAILURES_WITHOUT_URL = 10
|
|
10
|
+
const DEFAULT_STOP_GRACE_MS = 5_000
|
|
11
|
+
|
|
12
|
+
export type CloudflareQuickProviderOptions = {
|
|
13
|
+
config: TunnelConfig
|
|
14
|
+
upstreamPort: number
|
|
15
|
+
onUrlChange: (url: string) => void
|
|
16
|
+
binary?: string
|
|
17
|
+
restartBackoffMs?: number[]
|
|
18
|
+
maxConsecutiveFailuresWithoutUrl?: number
|
|
19
|
+
stopGraceMs?: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type CloudflareQuickProviderHandle = TunnelProviderHandle & {
|
|
23
|
+
tail: () => string[]
|
|
24
|
+
subscribeToLogs: (cb: LogLineSubscriber) => Unsubscribe
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function createCloudflareQuickProvider(options: CloudflareQuickProviderOptions): CloudflareQuickProviderHandle {
|
|
28
|
+
const { config, upstreamPort, onUrlChange } = options
|
|
29
|
+
if (config.provider !== 'cloudflare-quick') {
|
|
30
|
+
throw new Error(`createCloudflareQuickProvider: provider must be 'cloudflare-quick', got '${config.provider}'`)
|
|
31
|
+
}
|
|
32
|
+
if (!Number.isInteger(upstreamPort) || upstreamPort < 1 || upstreamPort > 65535) {
|
|
33
|
+
throw new Error(`tunnel '${config.name}' (cloudflare-quick): upstreamPort must be a valid TCP port`)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const binary = options.binary ?? DEFAULT_BINARY
|
|
37
|
+
const restartBackoffMs = options.restartBackoffMs ?? DEFAULT_RESTART_BACKOFF_MS
|
|
38
|
+
const maxConsecutiveFailuresWithoutUrl = options.maxConsecutiveFailuresWithoutUrl ?? DEFAULT_MAX_FAILURES_WITHOUT_URL
|
|
39
|
+
const stopGraceMs = options.stopGraceMs ?? DEFAULT_STOP_GRACE_MS
|
|
40
|
+
const logs = createLogRing()
|
|
41
|
+
const state: TunnelState = {
|
|
42
|
+
name: config.name,
|
|
43
|
+
provider: 'cloudflare-quick',
|
|
44
|
+
for: config.for,
|
|
45
|
+
url: null,
|
|
46
|
+
status: 'stopped',
|
|
47
|
+
lastUrlAt: null,
|
|
48
|
+
detail: '',
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let started = false
|
|
52
|
+
let stopping = false
|
|
53
|
+
let proc: ReturnType<typeof Bun.spawn> | null = null
|
|
54
|
+
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
|
55
|
+
let restartFailuresWithoutUrl = 0
|
|
56
|
+
let attemptEmittedUrl = false
|
|
57
|
+
|
|
58
|
+
async function launch(): Promise<void> {
|
|
59
|
+
if (!started || stopping) return
|
|
60
|
+
|
|
61
|
+
attemptEmittedUrl = false
|
|
62
|
+
state.status = 'starting'
|
|
63
|
+
state.detail = 'starting cloudflared'
|
|
64
|
+
const spawned = Bun.spawn(
|
|
65
|
+
[binary, 'tunnel', '--url', `http://127.0.0.1:${upstreamPort}`, '--no-autoupdate', '--metrics', '127.0.0.1:0'],
|
|
66
|
+
{ stdout: 'ignore', stderr: 'pipe' },
|
|
67
|
+
)
|
|
68
|
+
proc = spawned
|
|
69
|
+
|
|
70
|
+
void pumpStderr(spawned.stderr, logs, (line) => {
|
|
71
|
+
const url = extractQuickTunnelUrl(line)
|
|
72
|
+
if (url === null) return
|
|
73
|
+
attemptEmittedUrl = true
|
|
74
|
+
restartFailuresWithoutUrl = 0
|
|
75
|
+
state.url = url
|
|
76
|
+
state.status = 'healthy'
|
|
77
|
+
state.lastUrlAt = Date.now()
|
|
78
|
+
state.detail = 'quick tunnel URL emitted'
|
|
79
|
+
onUrlChange(url)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
void spawned.exited.then((code) => {
|
|
83
|
+
if (proc !== spawned) return
|
|
84
|
+
proc = null
|
|
85
|
+
if (!started || stopping) return
|
|
86
|
+
handleExit(code)
|
|
87
|
+
})
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function handleExit(code: number): void {
|
|
91
|
+
if (!attemptEmittedUrl) restartFailuresWithoutUrl += 1
|
|
92
|
+
if (restartFailuresWithoutUrl >= maxConsecutiveFailuresWithoutUrl) {
|
|
93
|
+
state.status = 'permanently-failed'
|
|
94
|
+
state.detail = `cloudflared exited ${code}; retry cap reached before URL emission`
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
state.status = 'unhealthy'
|
|
99
|
+
state.detail = `cloudflared exited ${code}; restarting`
|
|
100
|
+
const delay = restartBackoffMs[Math.min(restartFailuresWithoutUrl - 1, restartBackoffMs.length - 1)] ?? 30_000
|
|
101
|
+
retryTimer = setTimeout(() => {
|
|
102
|
+
retryTimer = null
|
|
103
|
+
void launch()
|
|
104
|
+
}, delay)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return {
|
|
108
|
+
async start(): Promise<void> {
|
|
109
|
+
if (started) return
|
|
110
|
+
started = true
|
|
111
|
+
stopping = false
|
|
112
|
+
restartFailuresWithoutUrl = 0
|
|
113
|
+
await launch()
|
|
114
|
+
},
|
|
115
|
+
async stop(): Promise<void> {
|
|
116
|
+
if (!started && proc === null) return
|
|
117
|
+
started = false
|
|
118
|
+
stopping = true
|
|
119
|
+
if (retryTimer !== null) {
|
|
120
|
+
clearTimeout(retryTimer)
|
|
121
|
+
retryTimer = null
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const running = proc
|
|
125
|
+
proc = null
|
|
126
|
+
if (running !== null) {
|
|
127
|
+
running.kill('SIGTERM')
|
|
128
|
+
await Promise.race([
|
|
129
|
+
running.exited,
|
|
130
|
+
sleep(stopGraceMs).then(() => {
|
|
131
|
+
running.kill('SIGKILL')
|
|
132
|
+
return running.exited
|
|
133
|
+
}),
|
|
134
|
+
])
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
stopping = false
|
|
138
|
+
state.status = 'stopped'
|
|
139
|
+
state.detail = ''
|
|
140
|
+
},
|
|
141
|
+
snapshot(): TunnelState {
|
|
142
|
+
return { ...state }
|
|
143
|
+
},
|
|
144
|
+
tail(): string[] {
|
|
145
|
+
return logs.snapshot()
|
|
146
|
+
},
|
|
147
|
+
subscribeToLogs(cb: LogLineSubscriber): Unsubscribe {
|
|
148
|
+
return logs.subscribe(cb)
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function pumpStderr(
|
|
154
|
+
stream: ReadableStream<Uint8Array> | null,
|
|
155
|
+
logs: LogRing,
|
|
156
|
+
onLine: (line: string) => void,
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
if (stream === null) return
|
|
159
|
+
const reader = stream.getReader()
|
|
160
|
+
const decoder = new TextDecoder()
|
|
161
|
+
let buffered = ''
|
|
162
|
+
try {
|
|
163
|
+
while (true) {
|
|
164
|
+
const { done, value } = await reader.read()
|
|
165
|
+
if (done) break
|
|
166
|
+
buffered += decoder.decode(value, { stream: true })
|
|
167
|
+
let newlineIndex = buffered.indexOf('\n')
|
|
168
|
+
while (newlineIndex !== -1) {
|
|
169
|
+
const line = buffered.slice(0, newlineIndex).replace(/\r$/, '')
|
|
170
|
+
logs.append(line)
|
|
171
|
+
onLine(line)
|
|
172
|
+
buffered = buffered.slice(newlineIndex + 1)
|
|
173
|
+
newlineIndex = buffered.indexOf('\n')
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
buffered += decoder.decode()
|
|
177
|
+
if (buffered !== '') {
|
|
178
|
+
const line = buffered.replace(/\r$/, '')
|
|
179
|
+
logs.append(line)
|
|
180
|
+
onLine(line)
|
|
181
|
+
}
|
|
182
|
+
} finally {
|
|
183
|
+
reader.releaseLock()
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function sleep(ms: number): Promise<void> {
|
|
188
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
189
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { TunnelConfig, TunnelProviderHandle, TunnelState } from '../types'
|
|
2
|
+
|
|
3
|
+
export type ExternalProviderOptions = {
|
|
4
|
+
config: TunnelConfig
|
|
5
|
+
onUrlChange: (url: string) => void
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createExternalProvider(options: ExternalProviderOptions): TunnelProviderHandle {
|
|
9
|
+
const { config, onUrlChange } = options
|
|
10
|
+
if (config.provider !== 'external') {
|
|
11
|
+
throw new Error(`createExternalProvider: provider must be 'external', got '${config.provider}'`)
|
|
12
|
+
}
|
|
13
|
+
const url = config.externalUrl
|
|
14
|
+
if (url === undefined || url.trim() === '') {
|
|
15
|
+
throw new Error(`tunnel '${config.name}' (external): externalUrl is required`)
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
let started = false
|
|
19
|
+
const state: TunnelState = {
|
|
20
|
+
name: config.name,
|
|
21
|
+
provider: 'external',
|
|
22
|
+
for: config.for,
|
|
23
|
+
url: null,
|
|
24
|
+
status: 'stopped',
|
|
25
|
+
lastUrlAt: null,
|
|
26
|
+
detail: '',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
async start(): Promise<void> {
|
|
31
|
+
if (started) return
|
|
32
|
+
started = true
|
|
33
|
+
state.url = url
|
|
34
|
+
state.status = 'healthy'
|
|
35
|
+
state.lastUrlAt = Date.now()
|
|
36
|
+
onUrlChange(url)
|
|
37
|
+
},
|
|
38
|
+
async stop(): Promise<void> {
|
|
39
|
+
if (!started) return
|
|
40
|
+
started = false
|
|
41
|
+
state.status = 'stopped'
|
|
42
|
+
},
|
|
43
|
+
snapshot(): TunnelState {
|
|
44
|
+
return { ...state }
|
|
45
|
+
},
|
|
46
|
+
tail(): string[] {
|
|
47
|
+
return []
|
|
48
|
+
},
|
|
49
|
+
subscribeToLogs(): () => void {
|
|
50
|
+
return () => {}
|
|
51
|
+
},
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Unsubscribe } from '@/stream'
|
|
2
|
+
|
|
3
|
+
export type TunnelProvider = 'external' | 'cloudflare-quick'
|
|
4
|
+
|
|
5
|
+
export type TunnelFor = { kind: 'channel'; name: string } | { kind: 'manual' }
|
|
6
|
+
|
|
7
|
+
export type TunnelConfig = {
|
|
8
|
+
name: string
|
|
9
|
+
provider: TunnelProvider
|
|
10
|
+
for: TunnelFor
|
|
11
|
+
externalUrl?: string
|
|
12
|
+
upstreamPort?: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type TunnelStatus = 'stopped' | 'starting' | 'healthy' | 'unhealthy' | 'permanently-failed'
|
|
16
|
+
|
|
17
|
+
export type TunnelState = {
|
|
18
|
+
name: string
|
|
19
|
+
provider: TunnelProvider
|
|
20
|
+
for: TunnelFor
|
|
21
|
+
url: string | null
|
|
22
|
+
status: TunnelStatus
|
|
23
|
+
lastUrlAt: number | null
|
|
24
|
+
detail: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export type TunnelProviderHandle = {
|
|
28
|
+
start: () => Promise<void>
|
|
29
|
+
stop: () => Promise<void>
|
|
30
|
+
snapshot: () => TunnelState
|
|
31
|
+
tail: () => string[]
|
|
32
|
+
subscribeToLogs: (cb: TunnelLogSubscriber) => Unsubscribe
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type TunnelLogSubscriber = (line: string) => void
|
|
36
|
+
|
|
37
|
+
export type TunnelUrlChangedPayload = {
|
|
38
|
+
kind: 'tunnel-url-changed'
|
|
39
|
+
tunnelName: string
|
|
40
|
+
url: string
|
|
41
|
+
for: TunnelFor
|
|
42
|
+
rotatedAt: string
|
|
43
|
+
}
|