@revenexx/studio-commerce-devkit 0.1.5 → 0.1.7
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 +17 -0
- package/app/layouts/default.vue +6 -2
- package/app/pages/functions.vue +228 -0
- package/nuxt.config.ts +9 -10
- package/package.json +3 -3
- package/server/routes/api/functions.get.ts +7 -0
- package/server/routes/gateway/[app]/[...path].ts +27 -0
- package/server/utils/functions.ts +167 -0
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
|
package/app/layouts/default.vue
CHANGED
|
@@ -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-
|
|
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,16 +90,12 @@ export default defineNuxtConfig({
|
|
|
90
90
|
plugins: [tailwindcss()],
|
|
91
91
|
server: {
|
|
92
92
|
allowedHosts: ['commerce-studio.rvnxx.test', 'localhost'],
|
|
93
|
-
//
|
|
94
|
-
// brand-font .woff2
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
pkgDir('@revenexx/studio-shared'),
|
|
100
|
-
pkgDir('@revenexx/studio-commerce'),
|
|
101
|
-
],
|
|
102
|
-
},
|
|
93
|
+
// Dev-only tool: serve any file. The studio packages' assets (e.g.
|
|
94
|
+
// @revenexx/studio's brand-font .woff2) live deep in the pnpm store; an
|
|
95
|
+
// explicit fs.allow list is too brittle there (symlink vs realpath, and
|
|
96
|
+
// pnpm peer-hash variants) → recurring "outside of Vite serving allow
|
|
97
|
+
// list" 403s. Disabling the allow-list check sidesteps the whole class.
|
|
98
|
+
fs: { strict: false },
|
|
103
99
|
},
|
|
104
100
|
},
|
|
105
101
|
solarIcons: {
|
|
@@ -115,6 +111,9 @@ export default defineNuxtConfig({
|
|
|
115
111
|
// Same-origin local shims (served by this host's server/routes):
|
|
116
112
|
consoleApi: '',
|
|
117
113
|
postgrestApi: '/pg',
|
|
114
|
+
// Functions console: the simulated API gateway + capability listing.
|
|
115
|
+
gatewayApi: '/gateway',
|
|
116
|
+
functionsApi: '/api/functions',
|
|
118
117
|
// Standalone platform provider (any non-empty token unlocks the shims):
|
|
119
118
|
devToken: 'dev',
|
|
120
119
|
devTenant: 'dev',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@revenexx/studio-commerce-devkit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
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-
|
|
30
|
-
"@revenexx/studio-
|
|
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,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
|
+
}
|