@olenbetong/appframe-vite 6.2.0 → 6.3.1
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/lib/cli-resources-add.js +9 -1
- package/lib/cli-resources-edit.js +9 -1
- package/lib/cli-resources-generate.js +3 -4
- package/lib/cli.js +7 -1
- package/lib/devtoolsServer.d.ts +7 -0
- package/lib/devtoolsServer.js +240 -0
- package/lib/index.d.ts +11 -1
- package/lib/index.js +23 -2
- package/lib/resourceGenerate.js +6 -4
- package/lib/resourcesConfig.d.ts +34 -6
- package/lib/resourcesConfig.js +78 -9
- package/package.json +11 -10
package/lib/cli-resources-add.js
CHANGED
|
@@ -159,7 +159,15 @@ export async function addResource() {
|
|
|
159
159
|
expose: exposeIt || undefined,
|
|
160
160
|
permissions: permissions,
|
|
161
161
|
maxRecords: maxRecords !== "50" ? Number(maxRecords) : undefined,
|
|
162
|
-
sortOrder: sortOrder
|
|
162
|
+
sortOrder: sortOrder
|
|
163
|
+
? sortOrder
|
|
164
|
+
.split(",")
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.map((s) => {
|
|
167
|
+
const [field, direction] = s.split(":");
|
|
168
|
+
return direction ? { field, direction } : { field };
|
|
169
|
+
})
|
|
170
|
+
: undefined,
|
|
163
171
|
fields: selectedFields.length > 0 ? selectedFields : undefined,
|
|
164
172
|
};
|
|
165
173
|
// Write to resources.yaml
|
|
@@ -153,7 +153,15 @@ export async function editResource(id) {
|
|
|
153
153
|
expose: exposeIt || undefined,
|
|
154
154
|
permissions,
|
|
155
155
|
maxRecords: maxRecords !== "50" ? Number(maxRecords) : undefined,
|
|
156
|
-
sortOrder: sortOrder
|
|
156
|
+
sortOrder: sortOrder
|
|
157
|
+
? sortOrder
|
|
158
|
+
.split(",")
|
|
159
|
+
.filter(Boolean)
|
|
160
|
+
.map((s) => {
|
|
161
|
+
const [field, direction] = s.split(":");
|
|
162
|
+
return direction ? { field, direction } : { field };
|
|
163
|
+
})
|
|
164
|
+
: undefined,
|
|
157
165
|
fields: selectedFields.length > 0 ? selectedFields : undefined,
|
|
158
166
|
};
|
|
159
167
|
// Update resources.yaml
|
|
@@ -22,10 +22,10 @@ export async function generateFromConfig(configPath) {
|
|
|
22
22
|
let errors = [];
|
|
23
23
|
for (let { entry } of allEntries) {
|
|
24
24
|
let options = entryToCLIOptions(entry, resourcesConfig.server ?? hostname);
|
|
25
|
-
options.output = resolve(process.cwd(), entry.output);
|
|
25
|
+
options.output = resolve(process.cwd(), entry.output ?? `src/data/${entry.id}.ts`);
|
|
26
26
|
try {
|
|
27
27
|
let content = await fetchAndGenerate(entry.resource, options, client);
|
|
28
|
-
let outputPath = resolve(process.cwd(), entry.output);
|
|
28
|
+
let outputPath = resolve(process.cwd(), entry.output ?? `src/data/${entry.id}.ts`);
|
|
29
29
|
await mkdir(dirname(outputPath), { recursive: true });
|
|
30
30
|
await writeFile(outputPath, content, "utf-8");
|
|
31
31
|
await formatWithBiome(outputPath);
|
|
@@ -38,7 +38,6 @@ export async function generateFromConfig(configPath) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
if (errors.length > 0) {
|
|
41
|
-
|
|
42
|
-
process.exit(1);
|
|
41
|
+
throw new Error(`${errors.length} resource(s) failed to generate:\n${errors.map((e) => ` ${e.id}: ${e.error}`).join("\n")}`);
|
|
43
42
|
}
|
|
44
43
|
}
|
package/lib/cli.js
CHANGED
|
@@ -34,7 +34,13 @@ resources
|
|
|
34
34
|
.option("-c, --config <path>", "Path to resources config file (default: resources.yaml)")
|
|
35
35
|
.action(async (opts) => {
|
|
36
36
|
let { generateFromConfig } = await import("./cli-resources-generate.js");
|
|
37
|
-
|
|
37
|
+
try {
|
|
38
|
+
await generateFromConfig(opts.config);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
console.error(`\n${error?.message ?? error}`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
38
44
|
});
|
|
39
45
|
resources
|
|
40
46
|
.command("add")
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Connect } from "vite";
|
|
2
|
+
/**
|
|
3
|
+
* Creates a Connect middleware that:
|
|
4
|
+
* 1. Serves the appframe-devtools static SPA at `/__appframe_devtools__/`
|
|
5
|
+
* 2. Exposes a REST API at `/__appframe_devtools__/api/resources` for resources.yaml CRUD
|
|
6
|
+
*/
|
|
7
|
+
export declare function createDevtoolsMiddleware(hostname: string): Connect.NextHandleFunction;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { createReadStream, existsSync, rmSync } from "node:fs";
|
|
2
|
+
import https from "node:https";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { dirname, extname, join } from "node:path";
|
|
5
|
+
import bodyParser from "body-parser";
|
|
6
|
+
import { login } from "./proxy.js";
|
|
7
|
+
import { readResourcesConfig, writeResourcesConfig } from "./resourcesConfig.js";
|
|
8
|
+
const require = createRequire(import.meta.url);
|
|
9
|
+
const ALL_SERVERS = ["dev.obet.no", "stage.obet.no", "test.obet.no"];
|
|
10
|
+
const MIME_TYPES = {
|
|
11
|
+
".html": "text/html; charset=utf-8",
|
|
12
|
+
".js": "application/javascript; charset=utf-8",
|
|
13
|
+
".css": "text/css; charset=utf-8",
|
|
14
|
+
".json": "application/json; charset=utf-8",
|
|
15
|
+
".svg": "image/svg+xml",
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".ico": "image/x-icon",
|
|
18
|
+
".woff2": "font/woff2",
|
|
19
|
+
".woff": "font/woff",
|
|
20
|
+
};
|
|
21
|
+
function getDevtoolsDist() {
|
|
22
|
+
try {
|
|
23
|
+
const pkgPath = require.resolve("@olenbetong/appframe-devtools/package.json");
|
|
24
|
+
return join(dirname(pkgPath), "dist");
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
function sendJson(res, status, data) {
|
|
31
|
+
const body = status === 204 ? "" : JSON.stringify(data);
|
|
32
|
+
res.statusCode = status;
|
|
33
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
34
|
+
res.end(body);
|
|
35
|
+
}
|
|
36
|
+
async function addResourceOnServer(hostname, cookies, dbObjectId, name) {
|
|
37
|
+
const cookieStr = Object.entries(cookies)
|
|
38
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
39
|
+
.join("; ");
|
|
40
|
+
// Name is required by the server; fall back to the DBObjectID itself
|
|
41
|
+
const body = JSON.stringify({
|
|
42
|
+
operation: "create",
|
|
43
|
+
resourceName: "API_Resources",
|
|
44
|
+
// fields + excludeFieldNames match the format generateApiDataHandler sends, which the server requires
|
|
45
|
+
fields: ["PrimKey", "Created", "CreatedBy", "Updated", "UpdatedBy", "CUT", "CDL", "DBObjectID", "Name"],
|
|
46
|
+
excludeFieldNames: true,
|
|
47
|
+
DBObjectID: dbObjectId,
|
|
48
|
+
Name: name ?? dbObjectId,
|
|
49
|
+
});
|
|
50
|
+
return new Promise((resolve) => {
|
|
51
|
+
const req = https.request({
|
|
52
|
+
hostname,
|
|
53
|
+
port: 443,
|
|
54
|
+
path: "/api/data",
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
"Content-Type": "application/json",
|
|
58
|
+
Accept: "application/json",
|
|
59
|
+
Cookie: cookieStr,
|
|
60
|
+
Origin: `https://${hostname}`,
|
|
61
|
+
"Content-Length": Buffer.byteLength(body),
|
|
62
|
+
},
|
|
63
|
+
}, (incoming) => {
|
|
64
|
+
let raw = "";
|
|
65
|
+
incoming.on("data", (chunk) => {
|
|
66
|
+
raw += chunk;
|
|
67
|
+
});
|
|
68
|
+
incoming.on("end", () => {
|
|
69
|
+
try {
|
|
70
|
+
const json = JSON.parse(raw);
|
|
71
|
+
if (json.error) {
|
|
72
|
+
resolve({ hostname, success: false, error: json.error });
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
resolve({ hostname, success: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
const ok = (incoming.statusCode ?? 0) < 400;
|
|
80
|
+
resolve({ hostname, success: ok, error: ok ? undefined : raw.slice(0, 200) });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
req.on("error", (err) => resolve({ hostname, success: false, error: err.message }));
|
|
85
|
+
req.write(body);
|
|
86
|
+
req.end();
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
async function handleApi(req, res, apiPath, hostname, next) {
|
|
90
|
+
// apiPath is the path after /__appframe_devtools__/api
|
|
91
|
+
// e.g. /config, /resources or /resources/dsMyObject
|
|
92
|
+
const segments = apiPath
|
|
93
|
+
.replace(/^\/resources/, "")
|
|
94
|
+
.split("/")
|
|
95
|
+
.filter(Boolean);
|
|
96
|
+
const method = req.method?.toUpperCase() ?? "GET";
|
|
97
|
+
if (apiPath === "/config" && method === "GET") {
|
|
98
|
+
return sendJson(res, 200, { hostname });
|
|
99
|
+
}
|
|
100
|
+
// Only handle /resources and /resources/:id
|
|
101
|
+
if (!apiPath.startsWith("/resources") && apiPath !== "/catalog") {
|
|
102
|
+
return next();
|
|
103
|
+
}
|
|
104
|
+
const id = segments[0]; // undefined for collection-level routes
|
|
105
|
+
try {
|
|
106
|
+
if (apiPath === "/catalog" && method === "POST") {
|
|
107
|
+
const { dbObjectId, name } = req.body;
|
|
108
|
+
if (!dbObjectId) {
|
|
109
|
+
return sendJson(res, 400, { error: "dbObjectId is required" });
|
|
110
|
+
}
|
|
111
|
+
const username = process.env.APPFRAME_LOGIN ?? "";
|
|
112
|
+
const password = process.env.APPFRAME_PWD ?? "";
|
|
113
|
+
if (!username || !password) {
|
|
114
|
+
return sendJson(res, 500, { error: "APPFRAME_LOGIN and APPFRAME_PWD environment variables must be set" });
|
|
115
|
+
}
|
|
116
|
+
const settled = await Promise.allSettled(ALL_SERVERS.map(async (server) => {
|
|
117
|
+
const cookies = await login(server, username, password, { silent: true });
|
|
118
|
+
return addResourceOnServer(server, cookies, dbObjectId, name);
|
|
119
|
+
}));
|
|
120
|
+
const results = settled.map((r, i) => r.status === "fulfilled"
|
|
121
|
+
? r.value
|
|
122
|
+
: { hostname: ALL_SERVERS[i], success: false, error: String(r.reason) });
|
|
123
|
+
return sendJson(res, 200, { results });
|
|
124
|
+
}
|
|
125
|
+
if (method === "GET" && !id) {
|
|
126
|
+
const config = await readResourcesConfig();
|
|
127
|
+
return sendJson(res, 200, config);
|
|
128
|
+
}
|
|
129
|
+
if (method === "POST" && !id) {
|
|
130
|
+
const { type, ...entry } = req.body;
|
|
131
|
+
const config = await readResourcesConfig();
|
|
132
|
+
if (type === "procedure") {
|
|
133
|
+
config.procedures = [...(config.procedures ?? []), entry];
|
|
134
|
+
}
|
|
135
|
+
else {
|
|
136
|
+
config.dataObjects = [...(config.dataObjects ?? []), entry];
|
|
137
|
+
}
|
|
138
|
+
await writeResourcesConfig(config);
|
|
139
|
+
return sendJson(res, 201, entry);
|
|
140
|
+
}
|
|
141
|
+
if (method === "PUT" && id) {
|
|
142
|
+
const updates = req.body;
|
|
143
|
+
const config = await readResourcesConfig();
|
|
144
|
+
let found = false;
|
|
145
|
+
for (const list of [config.dataObjects ?? [], config.procedures ?? []]) {
|
|
146
|
+
const idx = list.findIndex((e) => e.id === id);
|
|
147
|
+
if (idx !== -1) {
|
|
148
|
+
list[idx] = { ...list[idx], ...updates };
|
|
149
|
+
found = true;
|
|
150
|
+
break;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!found) {
|
|
154
|
+
return sendJson(res, 404, { error: `Resource '${id}' not found` });
|
|
155
|
+
}
|
|
156
|
+
await writeResourcesConfig(config);
|
|
157
|
+
return sendJson(res, 200, { id, ...updates });
|
|
158
|
+
}
|
|
159
|
+
if (method === "DELETE" && id) {
|
|
160
|
+
const config = await readResourcesConfig();
|
|
161
|
+
let found = false;
|
|
162
|
+
let outputPath;
|
|
163
|
+
for (const key of ["dataObjects", "procedures"]) {
|
|
164
|
+
const list = config[key] ?? [];
|
|
165
|
+
const idx = list.findIndex((e) => e.id === id);
|
|
166
|
+
if (idx !== -1) {
|
|
167
|
+
outputPath = list[idx].output;
|
|
168
|
+
list.splice(idx, 1);
|
|
169
|
+
found = true;
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (!found) {
|
|
174
|
+
return sendJson(res, 404, { error: `Resource '${id}' not found` });
|
|
175
|
+
}
|
|
176
|
+
await writeResourcesConfig(config);
|
|
177
|
+
if (outputPath) {
|
|
178
|
+
try {
|
|
179
|
+
rmSync(outputPath, { force: true });
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
// non-fatal — file may not exist yet
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return sendJson(res, 204, null);
|
|
186
|
+
}
|
|
187
|
+
return next();
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
return sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) });
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Creates a Connect middleware that:
|
|
195
|
+
* 1. Serves the appframe-devtools static SPA at `/__appframe_devtools__/`
|
|
196
|
+
* 2. Exposes a REST API at `/__appframe_devtools__/api/resources` for resources.yaml CRUD
|
|
197
|
+
*/
|
|
198
|
+
export function createDevtoolsMiddleware(hostname) {
|
|
199
|
+
const jsonParser = bodyParser.json();
|
|
200
|
+
const devtoolsDist = getDevtoolsDist();
|
|
201
|
+
return (req, res, next) => {
|
|
202
|
+
// req.url here is the path after /__appframe_devtools__ (Connect strips the prefix)
|
|
203
|
+
const url = req.url ?? "/";
|
|
204
|
+
// REST API
|
|
205
|
+
if (url.startsWith("/api/")) {
|
|
206
|
+
jsonParser(req, res, () => {
|
|
207
|
+
handleApi(req, res, url.slice(4), hostname, next).catch(next);
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
// Static files
|
|
212
|
+
if (!devtoolsDist) {
|
|
213
|
+
res.statusCode = 503;
|
|
214
|
+
res.setHeader("Content-Type", "text/plain");
|
|
215
|
+
res.end("@olenbetong/appframe-devtools not found or not built.\n" +
|
|
216
|
+
"Run: pnpm --filter @olenbetong/appframe-devtools build");
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
const safePath = url.split("?")[0] || "/";
|
|
220
|
+
const filePath = join(devtoolsDist, safePath === "/" ? "index.html" : safePath);
|
|
221
|
+
if (existsSync(filePath)) {
|
|
222
|
+
const mime = MIME_TYPES[extname(filePath)] ?? "application/octet-stream";
|
|
223
|
+
res.setHeader("Content-Type", mime);
|
|
224
|
+
createReadStream(filePath).pipe(res);
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
// SPA fallback - all unknown paths serve index.html
|
|
228
|
+
const indexPath = join(devtoolsDist, "index.html");
|
|
229
|
+
if (existsSync(indexPath)) {
|
|
230
|
+
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
|
231
|
+
createReadStream(indexPath).pipe(res);
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
res.statusCode = 404;
|
|
235
|
+
res.setHeader("Content-Type", "text/plain");
|
|
236
|
+
res.end("DevTools app not built yet.\n" + "Run: pnpm --filter @olenbetong/appframe-devtools build");
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
}
|
package/lib/index.d.ts
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
import type { Plugin } from "vite";
|
|
2
2
|
import { addAppframeBuildConfig } from "./build.js";
|
|
3
3
|
import { createDevMiddleware } from "./devServer.js";
|
|
4
|
-
export
|
|
4
|
+
export interface AppframePluginOptions {
|
|
5
|
+
/**
|
|
6
|
+
* Whether to automatically generate TypeScript types from the Appframe article
|
|
7
|
+
* when the dev server starts. Set to `false` to skip type generation (e.g. when
|
|
8
|
+
* running Storybook or another tool that doesn't have an article context).
|
|
9
|
+
*
|
|
10
|
+
* @default true
|
|
11
|
+
*/
|
|
12
|
+
generateTypes?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export default function appframe(options?: AppframePluginOptions): Plugin;
|
|
5
15
|
export { addAppframeBuildConfig, createDevMiddleware };
|
package/lib/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { watch } from "chokidar";
|
|
|
4
4
|
import { addAppframeBuildConfig } from "./build.js";
|
|
5
5
|
import { generateFromConfig } from "./cli-resources-generate.js";
|
|
6
6
|
import { createDevMiddleware, getLoginInfo, getProxyRoutes } from "./devServer.js";
|
|
7
|
+
import { createDevtoolsMiddleware } from "./devtoolsServer.js";
|
|
7
8
|
import { runGenerateTypes } from "./generateTypes.js";
|
|
8
9
|
import { localizeMiddleware } from "./localization.js";
|
|
9
10
|
import { checkSession, getLastSession, login } from "./proxy.js";
|
|
@@ -31,7 +32,8 @@ catch (error) {
|
|
|
31
32
|
console.log(createLogMessage(`failed to watch package.json: ${error.message}`, { type: "warn" }));
|
|
32
33
|
}
|
|
33
34
|
const jsonParser = bodyParser.json();
|
|
34
|
-
export default function appframe() {
|
|
35
|
+
export default function appframe(options = {}) {
|
|
36
|
+
let { generateTypes = true } = options;
|
|
35
37
|
return {
|
|
36
38
|
name: "appframe",
|
|
37
39
|
resolveId(source) {
|
|
@@ -143,7 +145,9 @@ export default function appframe() {
|
|
|
143
145
|
let { appframe, hostname, username, password } = await getLoginInfo();
|
|
144
146
|
server = _server;
|
|
145
147
|
// Run type generation in the background — doesn't block the dev server from starting.
|
|
146
|
-
|
|
148
|
+
if (generateTypes) {
|
|
149
|
+
runGenerateTypes(hostname, username, password, appframe, _server.config.logger);
|
|
150
|
+
}
|
|
147
151
|
// Watch resources.yaml and regenerate on change
|
|
148
152
|
let resourcesConfigPath = resolve(process.cwd(), RESOURCES_CONFIG_FILE);
|
|
149
153
|
if (resourcesWatcher) {
|
|
@@ -166,6 +170,8 @@ export default function appframe() {
|
|
|
166
170
|
}
|
|
167
171
|
}, 300);
|
|
168
172
|
});
|
|
173
|
+
// DevTools panel: serve the devtools app + REST API at /__appframe_devtools__/
|
|
174
|
+
_server.middlewares.use("/__appframe_devtools__", createDevtoolsMiddleware(hostname));
|
|
169
175
|
_server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, jsonParser);
|
|
170
176
|
_server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, localizeMiddleware);
|
|
171
177
|
_server.middlewares.use("/data/Logger/LogError", jsonParser);
|
|
@@ -182,6 +188,21 @@ export default function appframe() {
|
|
|
182
188
|
_server.middlewares.use(createDevMiddleware(_server));
|
|
183
189
|
};
|
|
184
190
|
},
|
|
191
|
+
transformIndexHtml(_html, ctx) {
|
|
192
|
+
// Only inject the toolbar in dev mode
|
|
193
|
+
if (command !== "serve")
|
|
194
|
+
return;
|
|
195
|
+
// Only inject for article pages (not the devtools app itself)
|
|
196
|
+
if (ctx.originalUrl?.startsWith("/__appframe_devtools__"))
|
|
197
|
+
return;
|
|
198
|
+
return [
|
|
199
|
+
{
|
|
200
|
+
tag: "script",
|
|
201
|
+
attrs: { src: "/__appframe_devtools__/toolbar.js", defer: true },
|
|
202
|
+
injectTo: "body",
|
|
203
|
+
},
|
|
204
|
+
];
|
|
205
|
+
},
|
|
185
206
|
};
|
|
186
207
|
}
|
|
187
208
|
export { addAppframeBuildConfig, createDevMiddleware };
|
package/lib/resourceGenerate.js
CHANGED
|
@@ -320,7 +320,7 @@ export function getProcedureDefinition(name, procDefinition, options) {
|
|
|
320
320
|
output.push(`export type ${paramTypeName} = null | undefined | Record<string, unknown>;\n`);
|
|
321
321
|
}
|
|
322
322
|
}
|
|
323
|
-
output.push(`export const ${procName} = new ${options.global ? "af." : ""}ProcedureAPI${options.types ? `<${paramTypeName}, ${options.typesJsonReturnType ?? "unknown"}>` : ""}({
|
|
323
|
+
output.push(`export const ${procName} = new ${options.global ? "af." : ""}ProcedureAPI${options.types ? `<${paramTypeName}, ${options.typesJsonReturnType ?? "{ Table?: unknown[] }"}>` : ""}({
|
|
324
324
|
procedureId: "${name}",
|
|
325
325
|
parameters: ${JSON.stringify(parameters, null, 2)},
|
|
326
326
|
timeout: 30000
|
|
@@ -539,7 +539,8 @@ export async function fetchResourceDefinition(client, resourceName) {
|
|
|
539
539
|
if (!response.ok) {
|
|
540
540
|
throw new Error(`Failed to fetch resource definition for '${resourceName}': ${response.status} ${response.statusText}`);
|
|
541
541
|
}
|
|
542
|
-
|
|
542
|
+
const json = await response.json();
|
|
543
|
+
return json.success ?? json;
|
|
543
544
|
}
|
|
544
545
|
/**
|
|
545
546
|
* Fetch the resource definition from the server, read types.json overrides, and
|
|
@@ -551,7 +552,7 @@ export async function fetchResourceDefinition(client, resourceName) {
|
|
|
551
552
|
*/
|
|
552
553
|
export async function fetchAndGenerate(resourceName, options, client) {
|
|
553
554
|
let definition = await fetchResourceDefinition(client, resourceName);
|
|
554
|
-
definition.Parameters = definition.Parameters.filter((p) => !["CUT", "CDL"].includes(p.Name));
|
|
555
|
+
definition.Parameters = (definition.Parameters ?? []).filter((p) => !["CUT", "CDL"].includes(p.Name));
|
|
555
556
|
let typesJson = {};
|
|
556
557
|
try {
|
|
557
558
|
typesJson = await importJson("./types.json", true);
|
|
@@ -560,7 +561,8 @@ export async function fetchAndGenerate(resourceName, options, client) {
|
|
|
560
561
|
// no types.json — that's fine
|
|
561
562
|
}
|
|
562
563
|
options.typesJsonParamOverrides = typesJson.parameterTypes?.[resourceName] ?? {};
|
|
563
|
-
|
|
564
|
+
// Structured returnType from resources.yaml takes precedence over the raw types.json string
|
|
565
|
+
options.typesJsonReturnType = options.typesJsonReturnType ?? typesJson.procedureReturnTypes?.[resourceName] ?? null;
|
|
564
566
|
return definition.ObjectType === "V"
|
|
565
567
|
? getDataObjectDefinition(resourceName, definition, options)
|
|
566
568
|
: getProcedureDefinition(resourceName, definition, options);
|
package/lib/resourcesConfig.d.ts
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
import type { CLIOptions } from "./resourceGenerate.js";
|
|
2
|
+
export type ReturnTypeField = {
|
|
3
|
+
/** TypeScript field name */
|
|
4
|
+
name: string;
|
|
5
|
+
/** TypeScript type, e.g. `string`, `number | null`, `Date` */
|
|
6
|
+
type: string;
|
|
7
|
+
};
|
|
8
|
+
export type ReturnTypeTable = {
|
|
9
|
+
/** Property key when multiple result-sets are returned. Omit for single-table procedures. */
|
|
10
|
+
table?: string;
|
|
11
|
+
fields: ReturnTypeField[];
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* Build an inline TypeScript type string from a structured returnType definition.
|
|
15
|
+
*
|
|
16
|
+
* The server always returns a DataSet serialized as `{ Table: T[], Table1: U[], ... }`.
|
|
17
|
+
* The first table is always `"Table"`, subsequent ones are `"Table1"`, `"Table2"`, etc.
|
|
18
|
+
* An explicit `table` name on the entry overrides the auto-generated key (for procedures
|
|
19
|
+
* that assign custom DataTable.TableName values).
|
|
20
|
+
*/
|
|
21
|
+
export declare function returnTypeToString(returnType: ReturnTypeTable[]): string;
|
|
2
22
|
export type ResourceEntry = {
|
|
3
23
|
/** Data object or procedure identifier used in the generated code (e.g. `dsAccountGroups`) */
|
|
4
24
|
id: string;
|
|
@@ -14,8 +34,11 @@ export type ResourceEntry = {
|
|
|
14
34
|
permissions?: string;
|
|
15
35
|
/** Maximum records to fetch (default `50`; use `-1` for all) */
|
|
16
36
|
maxRecords?: number;
|
|
17
|
-
/** Sort order
|
|
18
|
-
sortOrder?:
|
|
37
|
+
/** Sort order as a list of sort objects */
|
|
38
|
+
sortOrder?: Array<{
|
|
39
|
+
field: string;
|
|
40
|
+
direction?: string;
|
|
41
|
+
}>;
|
|
19
42
|
/** Name of master data object (optionally `name:importPath` to import from another file) */
|
|
20
43
|
master?: string;
|
|
21
44
|
/** Link fields to master data object (comma-separated or array) */
|
|
@@ -30,14 +53,19 @@ export type ResourceEntry = {
|
|
|
30
53
|
overrides?: string | string[];
|
|
31
54
|
/** Fetch distinct rows */
|
|
32
55
|
distinct?: boolean;
|
|
33
|
-
/** Aggregate bindings for non-grouped fields
|
|
34
|
-
aggregates?:
|
|
35
|
-
|
|
36
|
-
|
|
56
|
+
/** Aggregate bindings for non-grouped fields */
|
|
57
|
+
aggregates?: Array<{
|
|
58
|
+
field: string;
|
|
59
|
+
aggregate: string;
|
|
60
|
+
}>;
|
|
61
|
+
/** Group-by fields */
|
|
62
|
+
groupBy?: string[];
|
|
37
63
|
/** Initial where clause */
|
|
38
64
|
where?: string;
|
|
39
65
|
/** Fields to include: comma-separated or array of field names */
|
|
40
66
|
fields?: string | string[];
|
|
67
|
+
/** Structured return type definition for procedures (generates inline TS type) */
|
|
68
|
+
returnType?: ReturnTypeTable[];
|
|
41
69
|
};
|
|
42
70
|
export type ResourcesConfig = {
|
|
43
71
|
/** Optional server hostname override (defaults to `appframe.proxy.hostname` from package.json) */
|
package/lib/resourcesConfig.js
CHANGED
|
@@ -2,6 +2,34 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { readFile, writeFile } from "node:fs/promises";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
5
|
+
/**
|
|
6
|
+
* Build an inline TypeScript type string from a structured returnType definition.
|
|
7
|
+
*
|
|
8
|
+
* The server always returns a DataSet serialized as `{ Table: T[], Table1: U[], ... }`.
|
|
9
|
+
* The first table is always `"Table"`, subsequent ones are `"Table1"`, `"Table2"`, etc.
|
|
10
|
+
* An explicit `table` name on the entry overrides the auto-generated key (for procedures
|
|
11
|
+
* that assign custom DataTable.TableName values).
|
|
12
|
+
*/
|
|
13
|
+
export function returnTypeToString(returnType) {
|
|
14
|
+
if (returnType.length === 0) {
|
|
15
|
+
return "unknown";
|
|
16
|
+
}
|
|
17
|
+
function tableRowType(table) {
|
|
18
|
+
if (table.fields.length === 0) {
|
|
19
|
+
return "Record<string, unknown>";
|
|
20
|
+
}
|
|
21
|
+
let props = table.fields.map((f) => `${f.name}: ${f.type}`).join("; ");
|
|
22
|
+
return `{ ${props} }`;
|
|
23
|
+
}
|
|
24
|
+
function tableKey(table, index) {
|
|
25
|
+
if (table.table) {
|
|
26
|
+
return table.table;
|
|
27
|
+
}
|
|
28
|
+
return index === 0 ? "Table" : `Table${index}`;
|
|
29
|
+
}
|
|
30
|
+
let props = returnType.map((t, i) => `${tableKey(t, i)}: ${tableRowType(t)}[]`).join("; ");
|
|
31
|
+
return `{ ${props} }`;
|
|
32
|
+
}
|
|
5
33
|
// ---------------------------------------------------------------------------
|
|
6
34
|
// Config file path
|
|
7
35
|
// ---------------------------------------------------------------------------
|
|
@@ -35,6 +63,32 @@ function arrayOrStringToString(value) {
|
|
|
35
63
|
return value.join(",");
|
|
36
64
|
return value;
|
|
37
65
|
}
|
|
66
|
+
/** Convert sortOrder (new object format, old string/array) to comma-separated CLIOptions string. */
|
|
67
|
+
function sortOrderToString(sortOrder) {
|
|
68
|
+
if (!sortOrder)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (typeof sortOrder === "string")
|
|
71
|
+
return sortOrder;
|
|
72
|
+
if (Array.isArray(sortOrder) && sortOrder.length === 0)
|
|
73
|
+
return undefined;
|
|
74
|
+
if (typeof sortOrder[0] === "string")
|
|
75
|
+
return sortOrder.join(",");
|
|
76
|
+
return sortOrder
|
|
77
|
+
.map((s) => (s.direction ? `${s.field}:${s.direction}` : s.field))
|
|
78
|
+
.join(",");
|
|
79
|
+
}
|
|
80
|
+
/** Convert aggregates (new object format, old string/array) to comma-separated CLIOptions string. */
|
|
81
|
+
function aggregatesToString(aggregates) {
|
|
82
|
+
if (!aggregates)
|
|
83
|
+
return undefined;
|
|
84
|
+
if (typeof aggregates === "string")
|
|
85
|
+
return aggregates;
|
|
86
|
+
if (Array.isArray(aggregates) && aggregates.length === 0)
|
|
87
|
+
return undefined;
|
|
88
|
+
if (typeof aggregates[0] === "string")
|
|
89
|
+
return aggregates.join(",");
|
|
90
|
+
return aggregates.map((a) => `${a.field}:${a.aggregate}`).join(",");
|
|
91
|
+
}
|
|
38
92
|
/** Convert a `ResourceEntry` into a `CLIOptions`-compatible object for code generation. */
|
|
39
93
|
export function entryToCLIOptions(entry, hostname) {
|
|
40
94
|
return {
|
|
@@ -44,7 +98,7 @@ export function entryToCLIOptions(entry, hostname) {
|
|
|
44
98
|
dynamic: entry.dynamic ?? false,
|
|
45
99
|
types: entry.types,
|
|
46
100
|
maxRecords: entry.maxRecords !== undefined ? String(entry.maxRecords) : "50",
|
|
47
|
-
sortOrder:
|
|
101
|
+
sortOrder: sortOrderToString(entry.sortOrder),
|
|
48
102
|
permissions: entry.permissions,
|
|
49
103
|
master: entry.master,
|
|
50
104
|
linkFields: arrayOrStringToString(entry.linkFields),
|
|
@@ -52,11 +106,12 @@ export function entryToCLIOptions(entry, hostname) {
|
|
|
52
106
|
unique: entry.unique,
|
|
53
107
|
overrides: arrayOrStringToString(entry.overrides),
|
|
54
108
|
distinct: entry.distinct,
|
|
55
|
-
aggregates:
|
|
56
|
-
groupBy:
|
|
109
|
+
aggregates: aggregatesToString(entry.aggregates),
|
|
110
|
+
groupBy: entry.groupBy?.join(","),
|
|
57
111
|
where: entry.where,
|
|
58
112
|
fields: arrayOrStringToString(entry.fields) ?? false,
|
|
59
|
-
output: resolve(process.cwd(), entry.output),
|
|
113
|
+
output: resolve(process.cwd(), entry.output ?? `src/data/${entry.id}.ts`),
|
|
114
|
+
typesJsonReturnType: entry.returnType ? returnTypeToString(entry.returnType) : undefined,
|
|
60
115
|
};
|
|
61
116
|
}
|
|
62
117
|
/** Convert a `CLIOptions` back to a `ResourceEntry` for saving to YAML. */
|
|
@@ -81,8 +136,15 @@ export function cliOptionsToEntry(resource, options) {
|
|
|
81
136
|
if (options.maxRecords && options.maxRecords !== "50") {
|
|
82
137
|
entry.maxRecords = Number(options.maxRecords);
|
|
83
138
|
}
|
|
84
|
-
if (options.sortOrder)
|
|
85
|
-
entry.sortOrder =
|
|
139
|
+
if (options.sortOrder) {
|
|
140
|
+
entry.sortOrder = options.sortOrder
|
|
141
|
+
.split(",")
|
|
142
|
+
.filter(Boolean)
|
|
143
|
+
.map((s) => {
|
|
144
|
+
const [field, direction] = s.split(":");
|
|
145
|
+
return direction ? { field, direction } : { field };
|
|
146
|
+
});
|
|
147
|
+
}
|
|
86
148
|
if (options.master)
|
|
87
149
|
entry.master = options.master;
|
|
88
150
|
if (options.linkFields)
|
|
@@ -97,10 +159,17 @@ export function cliOptionsToEntry(resource, options) {
|
|
|
97
159
|
entry.overrides = maybeArray(options.overrides) ?? options.overrides;
|
|
98
160
|
if (options.distinct)
|
|
99
161
|
entry.distinct = true;
|
|
100
|
-
if (options.aggregates)
|
|
101
|
-
entry.aggregates =
|
|
162
|
+
if (options.aggregates) {
|
|
163
|
+
entry.aggregates = options.aggregates
|
|
164
|
+
.split(",")
|
|
165
|
+
.filter(Boolean)
|
|
166
|
+
.map((a) => {
|
|
167
|
+
const [field, aggregate] = a.split(":");
|
|
168
|
+
return { field, aggregate: aggregate ?? "" };
|
|
169
|
+
});
|
|
170
|
+
}
|
|
102
171
|
if (options.groupBy)
|
|
103
|
-
entry.groupBy =
|
|
172
|
+
entry.groupBy = options.groupBy.split(",").filter(Boolean);
|
|
104
173
|
if (options.where)
|
|
105
174
|
entry.where = options.where;
|
|
106
175
|
if (options.fields && typeof options.fields === "string") {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@olenbetong/appframe-vite",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.3.1",
|
|
4
4
|
"description": "Tools to use and deploy Vite applications to Appframe",
|
|
5
5
|
"main": "./lib/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -29,24 +29,25 @@
|
|
|
29
29
|
"author": "Bjørnar Vister Hansen <bvh@olenbetong.no>",
|
|
30
30
|
"license": "MIT",
|
|
31
31
|
"dependencies": {
|
|
32
|
+
"@olenbetong/appframe-data": "1.5.0",
|
|
32
33
|
"body-parser": "^2.2.2",
|
|
33
|
-
"chalk": "^5.
|
|
34
|
+
"chalk": "^5.6.2",
|
|
34
35
|
"chokidar": "^5.0.0",
|
|
35
|
-
"commander": "^14.0.
|
|
36
|
-
"dotenv": "^17.2
|
|
36
|
+
"commander": "^14.0.3",
|
|
37
|
+
"dotenv": "^17.4.2",
|
|
37
38
|
"fuzzy": "^0.1.3",
|
|
38
|
-
"inquirer": "^
|
|
39
|
+
"inquirer": "^13.4.3",
|
|
39
40
|
"inquirer-autocomplete-standalone": "^0.8.1",
|
|
40
41
|
"jsdom": "29.1.1",
|
|
41
|
-
"rollup-plugin-visualizer": "^
|
|
42
|
-
"yaml": "^2.
|
|
43
|
-
"@olenbetong/appframe-
|
|
42
|
+
"rollup-plugin-visualizer": "^7.0.1",
|
|
43
|
+
"yaml": "^2.9.0",
|
|
44
|
+
"@olenbetong/appframe-devtools": "0.2.0"
|
|
44
45
|
},
|
|
45
46
|
"devDependencies": {
|
|
46
47
|
"@types/jsdom": "^27.0.0",
|
|
47
|
-
"@types/node": "25.
|
|
48
|
+
"@types/node": "25.9.1",
|
|
48
49
|
"typescript": "6.0.3",
|
|
49
|
-
"vite": "8.0.
|
|
50
|
+
"vite": "8.0.14"
|
|
50
51
|
},
|
|
51
52
|
"peerDependencies": {
|
|
52
53
|
"vite": ">=5.0.0"
|