lopata 0.8.4 → 0.10.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/dist/dashboard/{chunk-yxzrcvyh.js → chunk-hnsny9g7.js} +304 -80
- package/dist/dashboard/{chunk-csyd2tq2.css → chunk-jzyhpjad.css} +8 -0
- package/dist/dashboard/index.html +1 -1
- package/package.json +1 -1
- package/src/api/dispatch.ts +2 -0
- package/src/api/handlers/routes.ts +36 -0
- package/src/api/index.ts +11 -2
- package/src/api/types.ts +15 -0
- package/src/cli/dev.ts +97 -10
- package/src/config.ts +1 -0
- package/src/lopata-config.ts +1 -0
- package/src/route-matcher.ts +192 -0
- package/src/vite-plugin/dev-server-plugin.ts +219 -56
- package/src/vite-plugin/index.ts +1 -1
- package/src/vite-plugin/modules-plugin.ts +3 -0
package/src/cli/dev.ts
CHANGED
|
@@ -3,7 +3,15 @@ Error.stackTraceLimit = 50
|
|
|
3
3
|
|
|
4
4
|
import '../plugin'
|
|
5
5
|
import path from 'node:path'
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
handleApiRequest,
|
|
8
|
+
setDashboardConfig,
|
|
9
|
+
setGenerationManager,
|
|
10
|
+
setHostRoutes,
|
|
11
|
+
setLopataConfig,
|
|
12
|
+
setRouteDispatcher,
|
|
13
|
+
setWorkerRegistry,
|
|
14
|
+
} from '../api'
|
|
7
15
|
import { QueuePullConsumer } from '../bindings/queue'
|
|
8
16
|
import type { AckRequest, PullRequest } from '../bindings/queue'
|
|
9
17
|
import { CFWebSocket } from '../bindings/websocket-pair'
|
|
@@ -14,6 +22,7 @@ import { FileWatcher } from '../file-watcher'
|
|
|
14
22
|
import { GenerationManager } from '../generation-manager'
|
|
15
23
|
import { loadLopataConfig } from '../lopata-config'
|
|
16
24
|
import { addCfProperty } from '../request-cf'
|
|
25
|
+
import { matchHost, RouteDispatcher } from '../route-matcher'
|
|
17
26
|
import { getTraceStore } from '../tracing/store'
|
|
18
27
|
import type { TraceEvent } from '../tracing/types'
|
|
19
28
|
import { WorkerRegistry } from '../worker-registry'
|
|
@@ -32,6 +41,9 @@ export async function run(ctx: CliContext) {
|
|
|
32
41
|
const lopataConfig = await loadLopataConfig(baseDir)
|
|
33
42
|
|
|
34
43
|
let manager: GenerationManager
|
|
44
|
+
let routeDispatcher: RouteDispatcher | undefined
|
|
45
|
+
let registry: WorkerRegistry | undefined
|
|
46
|
+
const hostRoutes: Array<{ pattern: string; manager: GenerationManager; workerName: string }> = []
|
|
35
47
|
|
|
36
48
|
if (lopataConfig) {
|
|
37
49
|
// ─── Multi-worker mode ─────────────────────────────────────────
|
|
@@ -48,7 +60,7 @@ export async function run(ctx: CliContext) {
|
|
|
48
60
|
console.warn(`[lopata] Unknown isolation mode "${lopataConfig.isolation}", using "dev"`)
|
|
49
61
|
}
|
|
50
62
|
|
|
51
|
-
|
|
63
|
+
registry = new WorkerRegistry()
|
|
52
64
|
|
|
53
65
|
// Load main worker config
|
|
54
66
|
const mainConfig = await loadConfig(lopataConfig.main, envFlag)
|
|
@@ -67,9 +79,11 @@ export async function run(ctx: CliContext) {
|
|
|
67
79
|
})
|
|
68
80
|
registry.register(mainConfig.name, mainManager, true)
|
|
69
81
|
|
|
70
|
-
// Load auxiliary workers
|
|
82
|
+
// Load auxiliary workers and collect their configs for route setup
|
|
83
|
+
const auxConfigs = new Map<string, import('../config').WranglerConfig>()
|
|
71
84
|
for (const workerDef of lopataConfig.workers ?? []) {
|
|
72
85
|
const auxConfig = await loadConfig(workerDef.config, envFlag)
|
|
86
|
+
auxConfigs.set(workerDef.name, auxConfig)
|
|
73
87
|
const auxBaseDir = path.dirname(workerDef.config)
|
|
74
88
|
console.log(`[lopata] Auxiliary worker: ${workerDef.name} (${auxConfig.name})`)
|
|
75
89
|
|
|
@@ -94,8 +108,17 @@ export async function run(ctx: CliContext) {
|
|
|
94
108
|
// File watcher for aux worker
|
|
95
109
|
const auxSrcDir = path.dirname(path.resolve(auxBaseDir, auxConfig.main))
|
|
96
110
|
const auxWatcher = new FileWatcher(auxSrcDir, () => {
|
|
97
|
-
auxManager.reload().then(gen => {
|
|
111
|
+
auxManager.reload().then(async gen => {
|
|
98
112
|
console.log(`[lopata] Auxiliary worker "${workerDef.name}" reloaded → generation ${gen.id}`)
|
|
113
|
+
// Re-read config and update routes in case routes changed
|
|
114
|
+
if (routeDispatcher) {
|
|
115
|
+
try {
|
|
116
|
+
const freshConfig = await loadConfig(workerDef.config, envFlag)
|
|
117
|
+
routeDispatcher.addRoutes(freshConfig, auxManager, workerDef.name)
|
|
118
|
+
} catch (err) {
|
|
119
|
+
console.warn(`[lopata] Failed to re-read config for "${workerDef.name}" routes:`, err)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
99
122
|
}).catch(err => {
|
|
100
123
|
console.error(`[lopata] Reload failed for "${workerDef.name}":`, err)
|
|
101
124
|
})
|
|
@@ -109,9 +132,45 @@ export async function run(ctx: CliContext) {
|
|
|
109
132
|
const firstGen = await mainManager.reload()
|
|
110
133
|
console.log(`[lopata] Main worker → generation ${firstGen.id}`)
|
|
111
134
|
|
|
135
|
+
// Warn if main worker has routes — they are ignored because main is the fallback
|
|
136
|
+
if (mainConfig.routes && mainConfig.routes.length > 0) {
|
|
137
|
+
console.warn(
|
|
138
|
+
'[lopata] Warning: main worker has "routes" in config — these are ignored in multi-worker mode (main worker is the fallback for unmatched requests)',
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Build route dispatcher for route-based worker selection (aux workers only — main is the fallback)
|
|
143
|
+
routeDispatcher = new RouteDispatcher(mainManager)
|
|
144
|
+
for (const workerDef of lopataConfig.workers ?? []) {
|
|
145
|
+
const auxConfig = auxConfigs.get(workerDef.name)
|
|
146
|
+
const auxMgr = registry.getManager(workerDef.name)
|
|
147
|
+
if (auxConfig && auxMgr) routeDispatcher.addRoutes(auxConfig, auxMgr, workerDef.name)
|
|
148
|
+
}
|
|
149
|
+
if (routeDispatcher.hasRoutes()) {
|
|
150
|
+
for (const r of routeDispatcher.getRegisteredRoutes()) {
|
|
151
|
+
console.log(`[lopata] Route: ${r.pattern} → ${r.workerName}`)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Build host-based routing map
|
|
156
|
+
for (const workerDef of lopataConfig.workers ?? []) {
|
|
157
|
+
if (!workerDef.hosts) continue
|
|
158
|
+
const auxMgr = registry.getManager(workerDef.name)
|
|
159
|
+
if (!auxMgr) continue
|
|
160
|
+
for (const host of workerDef.hosts) {
|
|
161
|
+
hostRoutes.push({ pattern: host, manager: auxMgr, workerName: workerDef.name })
|
|
162
|
+
console.log(`[lopata] Host route: ${host} → ${workerDef.name}`)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (hostRoutes.length > 0) {
|
|
167
|
+
setHostRoutes(hostRoutes.map(hr => ({ pattern: hr.pattern, workerName: hr.workerName })))
|
|
168
|
+
}
|
|
169
|
+
|
|
112
170
|
manager = mainManager
|
|
113
171
|
setGenerationManager(manager)
|
|
114
172
|
setWorkerRegistry(registry)
|
|
173
|
+
setRouteDispatcher(routeDispatcher)
|
|
115
174
|
|
|
116
175
|
// File watcher for main worker
|
|
117
176
|
const mainSrcDir = path.dirname(path.resolve(mainBaseDir, mainConfig.main))
|
|
@@ -201,9 +260,10 @@ export async function run(ctx: CliContext) {
|
|
|
201
260
|
}
|
|
202
261
|
}
|
|
203
262
|
|
|
204
|
-
// Email handler: POST /cdn-cgi/handler/email?from=...&to
|
|
263
|
+
// Email handler: POST /cdn-cgi/handler/email?from=...&to=...&worker=<name>
|
|
205
264
|
if (url.pathname === '/cdn-cgi/handler/email' && request.method === 'POST') {
|
|
206
|
-
const
|
|
265
|
+
const targetManager = resolveWorkerParam(url, registry, manager)
|
|
266
|
+
const gen = targetManager.active
|
|
207
267
|
if (!gen) return new Response('No active generation', { status: 503 })
|
|
208
268
|
const from = url.searchParams.get('from') ?? ''
|
|
209
269
|
const to = url.searchParams.get('to') ?? ''
|
|
@@ -211,16 +271,31 @@ export async function run(ctx: CliContext) {
|
|
|
211
271
|
return gen.callEmail(new Uint8Array(raw), from, to)
|
|
212
272
|
}
|
|
213
273
|
|
|
214
|
-
// Manual trigger: GET /cdn-cgi/handler/scheduled?cron=<expression>
|
|
274
|
+
// Manual trigger: GET /cdn-cgi/handler/scheduled?cron=<expression>&worker=<name>
|
|
215
275
|
if (url.pathname === '/cdn-cgi/handler/scheduled') {
|
|
216
|
-
const
|
|
276
|
+
const targetManager = resolveWorkerParam(url, registry, manager)
|
|
277
|
+
const gen = targetManager.active
|
|
217
278
|
if (!gen) return new Response('No active generation', { status: 503 })
|
|
218
279
|
const cronExpr = url.searchParams.get('cron') ?? '* * * * *'
|
|
219
280
|
return gen.callScheduled(cronExpr)
|
|
220
281
|
}
|
|
221
282
|
|
|
222
|
-
//
|
|
223
|
-
|
|
283
|
+
// Host-based dispatch: match Host header against configured patterns
|
|
284
|
+
if (hostRoutes.length > 0) {
|
|
285
|
+
const hostHeader = request.headers.get('host') ?? ''
|
|
286
|
+
const hostname = hostHeader.split(':')[0] ?? ''
|
|
287
|
+
for (const hr of hostRoutes) {
|
|
288
|
+
if (matchHost(hostname, hr.pattern)) {
|
|
289
|
+
const gen = hr.manager.active
|
|
290
|
+
if (!gen) return new Response('No active generation', { status: 503 })
|
|
291
|
+
return (await gen.callFetch(request, server)) as Response
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Delegate to active generation (route-based dispatch in multi-worker mode)
|
|
297
|
+
const targetManager = routeDispatcher ? routeDispatcher.resolve(url.pathname) : manager
|
|
298
|
+
const gen = targetManager.active
|
|
224
299
|
if (!gen) {
|
|
225
300
|
return new Response('No active generation', { status: 503 })
|
|
226
301
|
}
|
|
@@ -402,6 +477,18 @@ export async function run(ctx: CliContext) {
|
|
|
402
477
|
await new Promise(() => {})
|
|
403
478
|
}
|
|
404
479
|
|
|
480
|
+
/** Resolve a ?worker= query param to the target GenerationManager, falling back to the main manager. */
|
|
481
|
+
function resolveWorkerParam(url: URL, registry: WorkerRegistry | undefined, fallback: GenerationManager): GenerationManager {
|
|
482
|
+
const workerName = url.searchParams.get('worker')
|
|
483
|
+
if (!workerName || !registry) return fallback
|
|
484
|
+
const target = registry.getManager(workerName)
|
|
485
|
+
if (!target) {
|
|
486
|
+
console.warn(`[lopata] Unknown worker "${workerName}" in ?worker= param, using main worker`)
|
|
487
|
+
return fallback
|
|
488
|
+
}
|
|
489
|
+
return target
|
|
490
|
+
}
|
|
491
|
+
|
|
405
492
|
function matchGlob(text: string, pattern: string): boolean {
|
|
406
493
|
// Placeholder approach: protect ** before escaping special chars
|
|
407
494
|
const regex = pattern
|
package/src/config.ts
CHANGED
|
@@ -58,6 +58,7 @@ export interface WranglerConfig {
|
|
|
58
58
|
instance_type?: string
|
|
59
59
|
name?: string
|
|
60
60
|
}[]
|
|
61
|
+
routes?: (string | { pattern: string; zone_name?: string; custom_domain?: boolean })[]
|
|
61
62
|
analytics_engine_datasets?: { binding: string; dataset?: string }[]
|
|
62
63
|
browser?: { binding: string }
|
|
63
64
|
version_metadata?: { binding: string }
|
package/src/lopata-config.ts
CHANGED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import type { WranglerConfig } from './config'
|
|
2
|
+
|
|
3
|
+
/** Minimal interface for the manager stored in route entries — allows both GenerationManager and Vite adapters. */
|
|
4
|
+
export interface RoutableManager {
|
|
5
|
+
readonly active: { callFetch(request: Request, server: unknown): Promise<Response | undefined> | Response | undefined } | null
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Extract the path portion from a Cloudflare route pattern.
|
|
10
|
+
* Strips the domain prefix: `example.com/api/*` → `/api/*`
|
|
11
|
+
* Handles patterns that are already path-only: `/api/*` → `/api/*`
|
|
12
|
+
* Strips query strings and hash fragments from the result.
|
|
13
|
+
*/
|
|
14
|
+
export function extractPathPattern(route: string | { pattern: string }): string {
|
|
15
|
+
let pattern = typeof route === 'string' ? route : route.pattern
|
|
16
|
+
|
|
17
|
+
// Strip protocol if present (e.g. `https://example.com/api/*`)
|
|
18
|
+
pattern = pattern.replace(/^https?:\/\//, '')
|
|
19
|
+
|
|
20
|
+
let path: string
|
|
21
|
+
if (pattern.startsWith('/')) {
|
|
22
|
+
path = pattern
|
|
23
|
+
} else {
|
|
24
|
+
// Strip domain (and optional port): find the first `/` after the domain
|
|
25
|
+
const slashIndex = pattern.indexOf('/')
|
|
26
|
+
if (slashIndex === -1) path = '/*' // Domain-only pattern like `example.com` matches everything
|
|
27
|
+
else path = pattern.slice(slashIndex)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Strip query string and hash fragment
|
|
31
|
+
const qIndex = path.indexOf('?')
|
|
32
|
+
if (qIndex !== -1) path = path.slice(0, qIndex)
|
|
33
|
+
const hIndex = path.indexOf('#')
|
|
34
|
+
if (hIndex !== -1) path = path.slice(0, hIndex)
|
|
35
|
+
|
|
36
|
+
return path
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Match a request pathname against a Cloudflare-style route pattern (path portion only).
|
|
41
|
+
* Supports trailing `*` as a wildcard that matches any suffix.
|
|
42
|
+
*
|
|
43
|
+
* Examples:
|
|
44
|
+
* - `/api/*` matches `/api/foo`, `/api/foo/bar`
|
|
45
|
+
* - `/api/users` matches only `/api/users`
|
|
46
|
+
* - `/*` matches everything
|
|
47
|
+
*/
|
|
48
|
+
export function matchRoute(pathname: string, pattern: string): boolean {
|
|
49
|
+
if (pattern === '/*' || pattern === '*') return true
|
|
50
|
+
|
|
51
|
+
if (pattern.endsWith('/*')) {
|
|
52
|
+
const prefix = pattern.slice(0, -2)
|
|
53
|
+
return pathname.startsWith(prefix + '/')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (pattern.endsWith('*')) {
|
|
57
|
+
const prefix = pattern.slice(0, -1)
|
|
58
|
+
return pathname.startsWith(prefix)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return pathname === pattern
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Match a hostname against a host pattern. Supports exact match and `*.domain` wildcards. */
|
|
65
|
+
export function matchHost(hostname: string, pattern: string): boolean {
|
|
66
|
+
if (pattern === hostname) return true
|
|
67
|
+
if (pattern.startsWith('*.')) {
|
|
68
|
+
const suffix = pattern.slice(1) // ".localhost"
|
|
69
|
+
// Must have a subdomain — bare hostname doesn't match *.localhost
|
|
70
|
+
return hostname.endsWith(suffix) && hostname.length > suffix.length
|
|
71
|
+
}
|
|
72
|
+
return false
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Count the number of path segments in a pattern (ignoring trailing wildcard). */
|
|
76
|
+
function segmentCount(pattern: string): number {
|
|
77
|
+
const clean = pattern.replace(/\/?\*$/, '')
|
|
78
|
+
if (clean === '' || clean === '/') return 0
|
|
79
|
+
return clean.split('/').filter(Boolean).length
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
interface RouteEntry {
|
|
83
|
+
pattern: string
|
|
84
|
+
workerName: string
|
|
85
|
+
manager: RoutableManager
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Dispatches requests to workers based on route patterns.
|
|
90
|
+
* Routes are sorted by specificity (most specific first).
|
|
91
|
+
*
|
|
92
|
+
* Only auxiliary workers should be added here — the main worker
|
|
93
|
+
* is the fallback and handles all unmatched requests.
|
|
94
|
+
*/
|
|
95
|
+
export class RouteDispatcher {
|
|
96
|
+
private routes: RouteEntry[] = []
|
|
97
|
+
private sorted = true
|
|
98
|
+
private fallback: RoutableManager
|
|
99
|
+
|
|
100
|
+
constructor(fallback: RoutableManager) {
|
|
101
|
+
this.fallback = fallback
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
addRoutes(config: WranglerConfig, manager: RoutableManager, workerName: string): void {
|
|
105
|
+
if (!config.routes) return
|
|
106
|
+
|
|
107
|
+
// Clear existing routes for this worker to support re-registration (e.g. config reload)
|
|
108
|
+
const hadRoutes = this.routes.length > 0
|
|
109
|
+
this.routes = this.routes.filter(r => r.workerName !== workerName)
|
|
110
|
+
if (hadRoutes && this.routes.length === 0) this.sorted = true
|
|
111
|
+
|
|
112
|
+
for (const route of config.routes) {
|
|
113
|
+
// Skip custom_domain entries — they are domain ownership claims, not request routing patterns
|
|
114
|
+
if (typeof route === 'object' && route.custom_domain) continue
|
|
115
|
+
|
|
116
|
+
const rawPattern = typeof route === 'string' ? route : route.pattern
|
|
117
|
+
if (!rawPattern || rawPattern.trim() === '') {
|
|
118
|
+
console.warn(`[lopata] Warning: empty route pattern in worker "${workerName}" — skipping`)
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const pattern = extractPathPattern(route)
|
|
123
|
+
|
|
124
|
+
// Warn about mid-pattern wildcards (CF only supports trailing wildcards)
|
|
125
|
+
const starIndex = pattern.indexOf('*')
|
|
126
|
+
if (starIndex !== -1 && starIndex < pattern.length - 1) {
|
|
127
|
+
console.warn(`[lopata] Warning: route pattern "${pattern}" has a wildcard not at the end — Cloudflare only supports trailing wildcards`)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Skip duplicate patterns from different workers (first registered wins)
|
|
131
|
+
const existing = this.routes.find(r => r.pattern === pattern)
|
|
132
|
+
if (existing) {
|
|
133
|
+
console.warn(
|
|
134
|
+
`[lopata] Warning: route pattern "${pattern}" is already registered by "${existing.workerName}" — skipping duplicate from "${workerName}"`,
|
|
135
|
+
)
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
this.routes.push({ pattern, workerName, manager })
|
|
140
|
+
this.sorted = false
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
removeWorkerRoutes(workerName: string): void {
|
|
145
|
+
this.routes = this.routes.filter(r => r.workerName !== workerName)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private ensureSorted(): void {
|
|
149
|
+
if (this.sorted) return
|
|
150
|
+
this.routes.sort((a, b) => {
|
|
151
|
+
const aHasWild = a.pattern.includes('*')
|
|
152
|
+
const bHasWild = b.pattern.includes('*')
|
|
153
|
+
// Non-wildcard patterns are more specific
|
|
154
|
+
if (aHasWild !== bHasWild) return aHasWild ? 1 : -1
|
|
155
|
+
// More segments = more specific
|
|
156
|
+
const segDiff = segmentCount(b.pattern) - segmentCount(a.pattern)
|
|
157
|
+
if (segDiff !== 0) return segDiff
|
|
158
|
+
// Slash-star (`/api/*`) is more specific than bare-star (`/api*`)
|
|
159
|
+
// because it only matches path-separated suffixes
|
|
160
|
+
const aSlashStar = a.pattern.endsWith('/*')
|
|
161
|
+
const bSlashStar = b.pattern.endsWith('/*')
|
|
162
|
+
if (aSlashStar !== bSlashStar) return aSlashStar ? -1 : 1
|
|
163
|
+
// Longer pattern string as tiebreaker
|
|
164
|
+
return b.pattern.length - a.pattern.length
|
|
165
|
+
})
|
|
166
|
+
this.sorted = true
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
resolve(pathname: string): RoutableManager {
|
|
170
|
+
this.ensureSorted()
|
|
171
|
+
for (const entry of this.routes) {
|
|
172
|
+
if (matchRoute(pathname, entry.pattern)) {
|
|
173
|
+
return entry.manager
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
return this.fallback
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Check whether the given manager is the fallback (main worker). */
|
|
180
|
+
isFallback(manager: RoutableManager): boolean {
|
|
181
|
+
return manager === this.fallback
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
hasRoutes(): boolean {
|
|
185
|
+
return this.routes.length > 0
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
getRegisteredRoutes(): Array<{ pattern: string; workerName: string }> {
|
|
189
|
+
this.ensureSorted()
|
|
190
|
+
return this.routes.map(r => ({ pattern: r.pattern, workerName: r.workerName }))
|
|
191
|
+
}
|
|
192
|
+
}
|