@revenexx/studio-commerce-devkit 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,23 @@ Requires the `@revenexx` registry in `.npmrc` (+ a `read:packages` token), same
95
95
  as any `@revenexx/*` install. Build output goes to `.commerce-studio/` in your
96
96
  app (gitignore it).
97
97
 
98
+ ## Functions console (`/functions`)
99
+
100
+ Run your app's **server-side code** locally against a **simulated API gateway** —
101
+ open **`/functions`** (nav link in the top strip). It lists your app's routes
102
+ from `manifest.capabilities.json`; pick one, fill path params / query / a JSON
103
+ body (prefilled from the request schema), and **Send** → you see the real
104
+ response (status, JSON, timing, and the function's `log()` output).
105
+
106
+ How it works: each app is a single function at `src/main.js`
107
+ (`@revenexx/app-sdk/router`). The gateway (`/gateway/<app>/<path…>`) builds an
108
+ open-runtimes `FnContext`, `require()`s `src/main.js`, and invokes it. The
109
+ function's data adapter is pointed at this host's `/pg` mock store
110
+ (`REVENEXX_DATA_ENDPOINT`), so **function reads/writes share the same data as
111
+ the Commerce Studio views** — create a market via `POST /markets`, then see it
112
+ in the `/commerce` list. Requires the app's deps installed (its
113
+ `@revenexx/app-sdk`); edits to `src/main.js` are picked up on the next run.
114
+
98
115
  ## Notes
99
116
 
100
117
  - Mock data is in-memory and regenerated on server restart; it's for *seeing the
@@ -8,8 +8,12 @@
8
8
 
9
9
  <template>
10
10
  <div class="flex h-screen w-screen flex-col overflow-hidden bg-background text-foreground">
11
- <header class="flex h-9 shrink-0 items-center gap-2 px-3 text-mono text-muted-foreground">
12
- Commerce Studio · standalone app-building
11
+ <header class="flex h-9 shrink-0 items-center gap-3 px-3 text-mono text-muted-foreground">
12
+ <span>Commerce Studio · standalone app-building</span>
13
+ <nav class="ml-2 flex items-center gap-3">
14
+ <NuxtLink to="/commerce" class="hover:text-foreground" active-class="text-foreground">Commerce</NuxtLink>
15
+ <NuxtLink to="/functions" class="hover:text-foreground" active-class="text-foreground">Functions</NuxtLink>
16
+ </nav>
13
17
  </header>
14
18
  <div class="min-h-0 flex-1">
15
19
  <slot />
@@ -0,0 +1,228 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * Functions console — a Postman-like surface to run an app's server-side
4
+ * function through the simulated API gateway (`/gateway/<app>/…`). Capabilities
5
+ * come from each app's manifest.capabilities.json; the response, timing and
6
+ * function logs come back from the gateway (dev-only devkit surface).
7
+ */
8
+ interface Capability {
9
+ capability: string
10
+ summary?: string
11
+ route: { method: string, path: string }
12
+ parameters?: { name: string, in: string, required?: boolean, schema?: any, description?: string }[]
13
+ request?: { properties?: Record<string, any> }
14
+ }
15
+ interface AppCaps { app: string, folder: string, capabilities: Capability[] }
16
+
17
+ useHead({ title: 'Functions · Commerce Studio' })
18
+
19
+ const { data } = await useFetch<{ apps: AppCaps[] }>('/api/functions')
20
+ const apps = computed(() => data.value?.apps ?? [])
21
+
22
+ const selectedApp = ref('')
23
+ watchEffect(() => { if (!selectedApp.value && apps.value.length) selectedApp.value = apps.value[0]!.app })
24
+ const currentApp = computed(() => apps.value.find(a => a.app === selectedApp.value) ?? null)
25
+
26
+ const filter = ref('')
27
+ const capabilities = computed(() => {
28
+ const caps = [...(currentApp.value?.capabilities ?? [])]
29
+ .sort((a, b) => a.route.path.localeCompare(b.route.path) || a.route.method.localeCompare(b.route.method))
30
+ const f = filter.value.trim().toLowerCase()
31
+ return f ? caps.filter(c => `${c.capability} ${c.route.method} ${c.route.path}`.toLowerCase().includes(f)) : caps
32
+ })
33
+
34
+ const selected = ref<Capability | null>(null)
35
+ const pathParams = reactive<Record<string, string>>({})
36
+ const queryParams = reactive<Record<string, string>>({})
37
+ const bodyText = ref('')
38
+
39
+ function pathParamNames(path: string): string[] {
40
+ return [...path.matchAll(/\{([^}]+)\}/g)].map(m => m[1]!)
41
+ }
42
+ function skeletonFor(schema: any): unknown {
43
+ const t = String(schema?.type)
44
+ if (schema?.enum?.length) return schema.enum[0]
45
+ if (t.includes('object')) return {}
46
+ if (t.includes('array')) return []
47
+ if (t.includes('integer') || t.includes('number')) return 0
48
+ if (t.includes('bool')) return false
49
+ return ''
50
+ }
51
+ function selectCap(c: Capability) {
52
+ selected.value = c
53
+ for (const k of Object.keys(pathParams)) delete pathParams[k]
54
+ for (const n of pathParamNames(c.route.path)) pathParams[n] = ''
55
+ for (const k of Object.keys(queryParams)) delete queryParams[k]
56
+ for (const p of c.parameters ?? []) if (p.in === 'query') queryParams[p.name] = ''
57
+ bodyText.value = (['POST', 'PUT', 'PATCH'].includes(c.route.method) && c.request?.properties)
58
+ ? JSON.stringify(Object.fromEntries(Object.entries(c.request.properties).map(([k, s]) => [k, skeletonFor(s)])), null, 2)
59
+ : ''
60
+ response.value = null
61
+ }
62
+
63
+ const running = ref(false)
64
+ const response = ref<{ status: number, body: unknown, logs: string[], durationMs: number, error?: string } | null>(null)
65
+
66
+ function resolvedPath(): string {
67
+ let p = selected.value!.route.path
68
+ for (const [k, v] of Object.entries(pathParams)) p = p.replace(`{${k}}`, encodeURIComponent(v || `{${k}}`))
69
+ return p
70
+ }
71
+
72
+ async function send() {
73
+ if (!selected.value || !currentApp.value) return
74
+ running.value = true
75
+ response.value = null
76
+ const method = selected.value.route.method
77
+ const query: Record<string, string> = {}
78
+ for (const [k, v] of Object.entries(queryParams)) if (v !== '') query[k] = v
79
+ let body: unknown
80
+ if (['POST', 'PUT', 'PATCH'].includes(method) && bodyText.value.trim()) {
81
+ try { body = JSON.parse(bodyText.value) }
82
+ catch (e: any) {
83
+ response.value = { status: 0, body: null, logs: [], durationMs: 0, error: `Invalid JSON body: ${e.message}` }
84
+ running.value = false
85
+ return
86
+ }
87
+ }
88
+ try {
89
+ const res = await $fetch.raw(`/gateway/${currentApp.value.app}${resolvedPath()}`, {
90
+ method: method as any,
91
+ query,
92
+ body,
93
+ ignoreResponseError: true,
94
+ })
95
+ const logs = JSON.parse(decodeURIComponent(res.headers.get('x-fn-logs') || '%5B%5D'))
96
+ response.value = { status: res.status, body: res._data, logs, durationMs: Number(res.headers.get('x-fn-duration') || 0) }
97
+ }
98
+ catch (e: any) {
99
+ response.value = { status: 0, body: null, logs: [], durationMs: 0, error: e.message }
100
+ }
101
+ finally {
102
+ running.value = false
103
+ }
104
+ }
105
+
106
+ function methodClass(m: string) {
107
+ return ({ GET: 'text-emerald-700 bg-emerald-50', POST: 'text-blue-700 bg-blue-50', PUT: 'text-amber-700 bg-amber-50', PATCH: 'text-amber-700 bg-amber-50', DELETE: 'text-rose-700 bg-rose-50' } as Record<string, string>)[m] || 'text-muted-foreground bg-muted'
108
+ }
109
+ function statusClass(s: number) {
110
+ if (s >= 200 && s < 300) return 'text-emerald-700 bg-emerald-50'
111
+ if (s >= 400 && s < 500) return 'text-amber-700 bg-amber-50'
112
+ return 'text-rose-700 bg-rose-50'
113
+ }
114
+ const prettyBody = computed(() => {
115
+ try { return JSON.stringify(response.value?.body, null, 2) }
116
+ catch { return String(response.value?.body) }
117
+ })
118
+ </script>
119
+
120
+ <template>
121
+ <div class="flex h-full min-h-0">
122
+ <!-- Left: app + capability list -->
123
+ <aside class="flex w-80 shrink-0 flex-col overflow-hidden border-r border-black/[0.06] bg-white">
124
+ <div class="border-b border-black/[0.06] px-4 pt-4 pb-3">
125
+ <h2 class="text-lg font-medium text-[#0B0522]">
126
+ Functions
127
+ </h2>
128
+ <p class="text-sm text-muted-foreground">
129
+ Run app code via the simulated gateway
130
+ </p>
131
+ </div>
132
+ <div class="space-y-2 px-3 py-2">
133
+ <select v-model="selectedApp" class="h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm">
134
+ <option v-for="a in apps" :key="a.app" :value="a.app">{{ a.app }}</option>
135
+ </select>
136
+ <input v-model="filter" placeholder="Filter routes…" class="h-8 w-full rounded-md border border-input bg-transparent px-2 text-sm">
137
+ </div>
138
+ <nav class="flex-1 overflow-y-auto px-2 pb-3">
139
+ <button
140
+ v-for="c in capabilities"
141
+ :key="c.capability + c.route.method"
142
+ class="group flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors hover:bg-primary/5"
143
+ :class="selected?.capability === c.capability && selected?.route.method === c.route.method ? 'bg-primary/10' : ''"
144
+ @click="selectCap(c)"
145
+ >
146
+ <span class="w-12 shrink-0 rounded px-1 py-0.5 text-center text-[10px] font-semibold" :class="methodClass(c.route.method)">{{ c.route.method }}</span>
147
+ <span class="min-w-0 flex-1">
148
+ <span class="block truncate font-mono text-xs text-foreground/90">{{ c.route.path }}</span>
149
+ <span class="block truncate text-[11px] text-muted-foreground">{{ c.capability }}</span>
150
+ </span>
151
+ </button>
152
+ <p v-if="!capabilities.length" class="px-2 pt-3 text-sm text-muted-foreground">
153
+ No capabilities. Does this app ship a manifest.capabilities.json?
154
+ </p>
155
+ </nav>
156
+ </aside>
157
+
158
+ <!-- Right: request builder + response -->
159
+ <section class="flex min-w-0 flex-1 flex-col overflow-y-auto bg-white/40 p-4">
160
+ <div v-if="!selected" class="m-auto text-sm text-muted-foreground">
161
+ Select a capability to run it.
162
+ </div>
163
+
164
+ <template v-else>
165
+ <!-- request line -->
166
+ <div class="flex items-center gap-2">
167
+ <span class="rounded px-1.5 py-0.5 text-xs font-semibold" :class="methodClass(selected.route.method)">{{ selected.route.method }}</span>
168
+ <code class="min-w-0 flex-1 truncate rounded-md border border-black/[0.06] bg-white px-2 py-1.5 text-sm">{{ resolvedPath() }}</code>
169
+ <button
170
+ class="shrink-0 rounded-md bg-primary px-4 py-1.5 text-sm font-medium text-primary-foreground disabled:opacity-50"
171
+ :disabled="running"
172
+ @click="send"
173
+ >
174
+ {{ running ? 'Running…' : 'Send' }}
175
+ </button>
176
+ </div>
177
+ <p v-if="selected.summary" class="mt-1 text-sm text-muted-foreground">
178
+ {{ selected.summary }}
179
+ </p>
180
+
181
+ <!-- params -->
182
+ <div class="mt-4 grid gap-4 sm:grid-cols-2">
183
+ <div v-if="Object.keys(pathParams).length">
184
+ <div class="text-mono text-muted-foreground">Path params</div>
185
+ <div class="mt-1 space-y-1">
186
+ <label v-for="(_v, k) in pathParams" :key="k" class="flex items-center gap-2 text-sm">
187
+ <span class="w-28 shrink-0 truncate font-mono text-xs text-foreground/70">{{ k }}</span>
188
+ <input v-model="pathParams[k]" class="h-8 flex-1 rounded-md border border-input bg-white px-2 text-sm">
189
+ </label>
190
+ </div>
191
+ </div>
192
+ <div v-if="Object.keys(queryParams).length">
193
+ <div class="text-mono text-muted-foreground">Query</div>
194
+ <div class="mt-1 space-y-1">
195
+ <label v-for="(_v, k) in queryParams" :key="k" class="flex items-center gap-2 text-sm">
196
+ <span class="w-28 shrink-0 truncate font-mono text-xs text-foreground/70">{{ k }}</span>
197
+ <input v-model="queryParams[k]" class="h-8 flex-1 rounded-md border border-input bg-white px-2 text-sm">
198
+ </label>
199
+ </div>
200
+ </div>
201
+ </div>
202
+
203
+ <!-- body -->
204
+ <div v-if="['POST', 'PUT', 'PATCH'].includes(selected.route.method)" class="mt-4">
205
+ <div class="text-mono text-muted-foreground">Request body (JSON)</div>
206
+ <textarea v-model="bodyText" rows="8" class="mt-1 w-full rounded-md border border-input bg-white p-2 font-mono text-xs" spellcheck="false" />
207
+ </div>
208
+
209
+ <!-- response -->
210
+ <div v-if="response" class="mt-5 rounded-lg border border-black/[0.06] bg-white">
211
+ <div class="flex items-center gap-2 border-b border-black/[0.06] px-3 py-2">
212
+ <span class="text-mono text-muted-foreground">Response</span>
213
+ <span v-if="response.status" class="rounded px-1.5 py-0.5 text-xs font-semibold" :class="statusClass(response.status)">{{ response.status }}</span>
214
+ <span class="ml-auto text-xs text-muted-foreground">{{ response.durationMs }} ms</span>
215
+ </div>
216
+ <p v-if="response.error" class="px-3 py-2 text-sm text-rose-700">
217
+ {{ response.error }}
218
+ </p>
219
+ <pre v-else class="max-h-[40vh] overflow-auto px-3 py-2 font-mono text-xs leading-relaxed">{{ prettyBody }}</pre>
220
+ <div v-if="response.logs?.length" class="border-t border-black/[0.06] px-3 py-2">
221
+ <div class="text-mono text-muted-foreground">Logs</div>
222
+ <pre class="mt-1 max-h-40 overflow-auto font-mono text-[11px] text-foreground/80">{{ response.logs.join('\n') }}</pre>
223
+ </div>
224
+ </div>
225
+ </template>
226
+ </section>
227
+ </div>
228
+ </template>
package/nuxt.config.ts CHANGED
@@ -90,6 +90,16 @@ export default defineNuxtConfig({
90
90
  plugins: [tailwindcss()],
91
91
  server: {
92
92
  allowedHosts: ['commerce-studio.rvnxx.test', 'localhost'],
93
+ // Allow Vite to serve the studio packages' assets (e.g. @revenexx/studio's
94
+ // brand-font .woff2 referenced by its fonts.css). They live deep in the
95
+ // pnpm store, outside the default fs allow-list → 403 without this.
96
+ fs: {
97
+ allow: [
98
+ pkgDir('@revenexx/studio'),
99
+ pkgDir('@revenexx/studio-shared'),
100
+ pkgDir('@revenexx/studio-commerce'),
101
+ ],
102
+ },
93
103
  },
94
104
  },
95
105
  solarIcons: {
@@ -105,6 +115,9 @@ export default defineNuxtConfig({
105
115
  // Same-origin local shims (served by this host's server/routes):
106
116
  consoleApi: '',
107
117
  postgrestApi: '/pg',
118
+ // Functions console: the simulated API gateway + capability listing.
119
+ gatewayApi: '/gateway',
120
+ functionsApi: '/api/functions',
108
121
  // Standalone platform provider (any non-empty token unlocks the shims):
109
122
  devToken: 'dev',
110
123
  devTenant: 'dev',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revenexx/studio-commerce-devkit",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "type": "module",
5
5
  "description": "Preview any revenexx app locally in the Commerce Studio: renders the app's cockpit.json with mock data, no platform. Add as a devDependency and run `commerce-studio` from your app repo.",
6
6
  "bin": {
@@ -26,8 +26,8 @@
26
26
  "tw-animate-css": "^1.4.0",
27
27
  "vue": "^3.5.26",
28
28
  "vue-router": "^4.6.4",
29
- "@revenexx/studio-shared": "0.1.2",
30
- "@revenexx/studio-commerce": "0.1.4"
29
+ "@revenexx/studio-commerce": "0.1.4",
30
+ "@revenexx/studio-shared": "0.1.2"
31
31
  },
32
32
  "license": "SEE LICENSE IN LICENSE",
33
33
  "scripts": {
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Lists each app's gateway contract (from manifest.capabilities.json) for the
3
+ * Functions console to render. Re-read per request → edit + reload reflects.
4
+ */
5
+ export default defineEventHandler(() => {
6
+ return { apps: loadCapabilities() }
7
+ })
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Simulated API gateway. Forwards any `<method> /gateway/<app>/<path…>` request
3
+ * to the app's function (its `src/main.js`, which self-routes via the app-sdk
4
+ * router). Returns the function's raw response body — so it behaves like the
5
+ * real gateway (curl/other tools work) — with the run's logs + timing exposed
6
+ * as response headers for the Functions console.
7
+ */
8
+ export default defineEventHandler(async (event) => {
9
+ const app = getRouterParam(event, 'app') || ''
10
+ const rest = getRouterParam(event, 'path') || ''
11
+ const path = `/${String(rest).replace(/^\/+/, '')}`
12
+ const method = event.method
13
+ const query = getQuery(event) as Record<string, any>
14
+ const body = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method)
15
+ ? await readBody(event).catch(() => undefined)
16
+ : undefined
17
+
18
+ const url = getRequestURL(event)
19
+ const origin = `${url.protocol}//${url.host}`
20
+
21
+ const result = await invokeFunction(app, { method, path, query, body, origin })
22
+
23
+ setResponseStatus(event, result.status)
24
+ setResponseHeader(event, 'x-fn-duration', String(result.durationMs))
25
+ setResponseHeader(event, 'x-fn-logs', encodeURIComponent(JSON.stringify(result.logs)))
26
+ return result.body
27
+ })
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Functions runner + simulated API gateway.
3
+ *
4
+ * Each revenexx app is a single serverless function at `src/main.js`
5
+ * (`module.exports = async (context) => …`) built on `@revenexx/app-sdk/router`
6
+ * — a self-routing HTTP handler. This util loads that function from disk and
7
+ * invokes it with a fabricated open-runtimes `FnContext`, so the devkit can run
8
+ * the app's real server code locally:
9
+ *
10
+ * • `loadCapabilities()` reads each app's `manifest.capabilities.json` (the
11
+ * gateway contract: method + path + params + request schema) for the UI.
12
+ * • `invokeFunction()` requires `src/main.js`, builds the FnContext, injects
13
+ * the gateway identity headers, and captures the response + logs + timing.
14
+ *
15
+ * Data unifies with the Commerce Studio: we point the function's runtime data
16
+ * adapter (`REVENEXX_DATA_ENDPOINT`) at this host's own `/pg` shim, so the
17
+ * function reads/writes the SAME mock store the AppRenderer renders.
18
+ */
19
+ import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'
20
+ import { join } from 'node:path'
21
+ import { createRequire } from 'node:module'
22
+ import { appsDir } from './commerce'
23
+
24
+ function readJson(path: string): any | null {
25
+ try {
26
+ return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null
27
+ }
28
+ catch {
29
+ return null
30
+ }
31
+ }
32
+
33
+ export interface CapabilityDef {
34
+ capability: string
35
+ summary?: string
36
+ version?: string
37
+ route: { method: string, path: string }
38
+ parameters?: { name: string, in: string, required?: boolean, schema?: any, description?: string }[]
39
+ request?: { properties?: Record<string, any> } & Record<string, any>
40
+ }
41
+
42
+ interface AppEntry {
43
+ name: string
44
+ folder: string
45
+ base: string
46
+ capabilities: CapabilityDef[]
47
+ }
48
+
49
+ /** Apps under APPS_DIR that expose a runnable function (`src/main.js`). */
50
+ export function appEntries(): AppEntry[] {
51
+ const dir = appsDir()
52
+ const one = (base: string, folder: string): AppEntry | null => {
53
+ if (!existsSync(join(base, 'src', 'main.js'))) return null
54
+ const manifest = readJson(join(base, 'manifest.json')) ?? {}
55
+ const caps = readJson(join(base, 'manifest.capabilities.json'))
56
+ const capabilities: CapabilityDef[] = Array.isArray(caps) ? caps.filter((c: any) => c?.route?.path) : []
57
+ return { name: manifest.name ?? folder, folder, base, capabilities }
58
+ }
59
+ // Single-app: APPS_DIR is itself an app (the `commerce-studio` bin case).
60
+ const self = one(dir, dir.split('/').filter(Boolean).pop() || 'app')
61
+ if (self) return [self]
62
+ const out: AppEntry[] = []
63
+ for (const folder of readdirSync(dir)) {
64
+ const base = join(dir, folder)
65
+ if (folder.startsWith('.') || !statSync(base).isDirectory()) continue
66
+ const e = one(base, folder)
67
+ if (e) out.push(e)
68
+ }
69
+ return out
70
+ }
71
+
72
+ /** The gateway contract per app, for the Functions console to render. */
73
+ export function loadCapabilities() {
74
+ return appEntries().map(e => ({ app: e.name, folder: e.folder, capabilities: e.capabilities }))
75
+ }
76
+
77
+ /**
78
+ * Minimal unsigned dev JWT carrying a tenant claim. NOT a real credential — it
79
+ * only satisfies `main.js`'s non-empty `x-revenexx-context` check and the
80
+ * runtime adapter's claim read; the `/pg` shim ignores auth entirely.
81
+ */
82
+ function devJwt(): string {
83
+ const b64 = (o: unknown) => Buffer.from(JSON.stringify(o)).toString('base64url')
84
+ return `${b64({ alg: 'none', typ: 'JWT' })}.${b64({ sub: 'dev', tenant_id: 'dev', iss: 'devkit' })}.`
85
+ }
86
+
87
+ export interface InvokeResult {
88
+ status: number
89
+ body: unknown
90
+ headers: Record<string, string>
91
+ logs: string[]
92
+ durationMs: number
93
+ }
94
+
95
+ export async function invokeFunction(appName: string, opts: {
96
+ method: string
97
+ path: string
98
+ query?: Record<string, any>
99
+ body?: unknown
100
+ headers?: Record<string, string>
101
+ origin: string
102
+ }): Promise<InvokeResult> {
103
+ const entry = appEntries().find(e => e.name === appName || e.folder === appName)
104
+ if (!entry) {
105
+ return { status: 404, body: { error: `Unknown app '${appName}'` }, headers: {}, logs: [], durationMs: 0 }
106
+ }
107
+ const mainPath = join(entry.base, 'src', 'main.js')
108
+
109
+ // Unify the function's data plane with the devkit's /pg mock store, and give
110
+ // the runtime adapter an identity (headers below also carry it).
111
+ process.env.REVENEXX_DATA_ENDPOINT = `${opts.origin}/pg`
112
+ process.env.REVENEXX_CONTEXT = devJwt()
113
+ process.env.REVENEXX_TENANT = 'dev'
114
+
115
+ const require = createRequire(mainPath)
116
+ // Bust the app's own module cache so edits to main.js / db.generated.js are
117
+ // picked up on each run (the SDK in node_modules stays cached).
118
+ for (const key of Object.keys(require.cache)) {
119
+ if (key.startsWith(entry.base)) delete require.cache[key]
120
+ }
121
+
122
+ const logs: string[] = []
123
+ const push = (m: unknown, prefix = '') => logs.push(prefix + (typeof m === 'string' ? m : JSON.stringify(m)))
124
+ let captured: { status: number, body: unknown, headers: Record<string, string> } | null = null
125
+
126
+ const res = {
127
+ json(data: unknown, statusCode = 200, headers: Record<string, string> = {}) {
128
+ captured = { status: statusCode, body: data, headers }
129
+ return data
130
+ },
131
+ send(body: unknown, statusCode = 200, headers: Record<string, string> = {}) {
132
+ captured = { status: statusCode, body, headers }
133
+ return body
134
+ },
135
+ }
136
+ const context = {
137
+ req: {
138
+ method: opts.method,
139
+ path: opts.path,
140
+ url: opts.path,
141
+ headers: { 'x-revenexx-context': devJwt(), 'x-revenexx-tenant': 'dev', ...(opts.headers ?? {}) },
142
+ query: opts.query ?? {},
143
+ body: opts.body ?? {},
144
+ bodyText: opts.body != null ? JSON.stringify(opts.body) : '',
145
+ },
146
+ res,
147
+ log: (m: unknown) => push(m),
148
+ error: (m: unknown) => push(m, 'ERROR: '),
149
+ }
150
+
151
+ const started = Date.now()
152
+ try {
153
+ const mod = require(mainPath)
154
+ const handler = typeof mod === 'function' ? mod : (mod?.default ?? mod)
155
+ if (typeof handler !== 'function') {
156
+ return { status: 500, body: { error: `app '${appName}' src/main.js does not export a function` }, headers: {}, logs, durationMs: Date.now() - started }
157
+ }
158
+ const ret = await handler(context)
159
+ // The SDK router answers via res.json(); fall back to the return value.
160
+ if (!captured) captured = { status: 200, body: ret ?? null, headers: {} }
161
+ }
162
+ catch (err: any) {
163
+ push(err?.stack ?? String(err), 'ERROR: ')
164
+ return { status: 500, body: { error: err?.message ?? String(err) }, headers: {}, logs, durationMs: Date.now() - started }
165
+ }
166
+ return { ...captured!, logs, durationMs: Date.now() - started }
167
+ }