@olenbetong/appframe-vite 6.1.2 → 6.3.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.
@@ -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 default function appframe(): Plugin;
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
@@ -1,16 +1,22 @@
1
+ import { resolve } from "node:path";
1
2
  import bodyParser from "body-parser";
2
3
  import { watch } from "chokidar";
3
4
  import { addAppframeBuildConfig } from "./build.js";
5
+ import { generateFromConfig } from "./cli-resources-generate.js";
4
6
  import { createDevMiddleware, getLoginInfo, getProxyRoutes } from "./devServer.js";
7
+ import { createDevtoolsMiddleware } from "./devtoolsServer.js";
5
8
  import { runGenerateTypes } from "./generateTypes.js";
6
9
  import { localizeMiddleware } from "./localization.js";
7
10
  import { checkSession, getLastSession, login } from "./proxy.js";
11
+ import { RESOURCES_CONFIG_FILE } from "./resourcesConfig.js";
8
12
  import { createLogMessage, getServerName } from "./utils.js";
9
13
  let command = "build";
10
14
  let interval;
11
15
  let server;
12
16
  let lastHostname;
13
17
  let watcher;
18
+ let resourcesWatcher = null;
19
+ let resourcesDebounce = null;
14
20
  try {
15
21
  let appPkgUrl = `file://${process.cwd()}/package.json`;
16
22
  watcher = watch(appPkgUrl).on("all", async () => {
@@ -26,7 +32,8 @@ catch (error) {
26
32
  console.log(createLogMessage(`failed to watch package.json: ${error.message}`, { type: "warn" }));
27
33
  }
28
34
  const jsonParser = bodyParser.json();
29
- export default function appframe() {
35
+ export default function appframe(options = {}) {
36
+ let { generateTypes = true } = options;
30
37
  return {
31
38
  name: "appframe",
32
39
  resolveId(source) {
@@ -138,7 +145,33 @@ export default function appframe() {
138
145
  let { appframe, hostname, username, password } = await getLoginInfo();
139
146
  server = _server;
140
147
  // Run type generation in the background — doesn't block the dev server from starting.
141
- runGenerateTypes(hostname, username, password, appframe, _server.config.logger);
148
+ if (generateTypes) {
149
+ runGenerateTypes(hostname, username, password, appframe, _server.config.logger);
150
+ }
151
+ // Watch resources.yaml and regenerate on change
152
+ let resourcesConfigPath = resolve(process.cwd(), RESOURCES_CONFIG_FILE);
153
+ if (resourcesWatcher) {
154
+ await resourcesWatcher.close();
155
+ }
156
+ resourcesWatcher = watch(resourcesConfigPath, { ignoreInitial: true }).on("all", () => {
157
+ if (resourcesDebounce)
158
+ clearTimeout(resourcesDebounce);
159
+ resourcesDebounce = setTimeout(async () => {
160
+ _server.config.logger.info(createLogMessage("resources.yaml changed — regenerating…", { source: hostname }));
161
+ try {
162
+ await generateFromConfig();
163
+ _server.config.logger.info(createLogMessage("resources regenerated", { source: hostname }));
164
+ }
165
+ catch (error) {
166
+ _server.config.logger.error(createLogMessage(`resources regeneration failed: ${error?.message ?? error}`, {
167
+ source: hostname,
168
+ type: "error",
169
+ }));
170
+ }
171
+ }, 300);
172
+ });
173
+ // DevTools panel: serve the devtools app + REST API at /__appframe_devtools__/
174
+ _server.middlewares.use("/__appframe_devtools__", createDevtoolsMiddleware(hostname));
142
175
  _server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, jsonParser);
143
176
  _server.middlewares.use(`/api/user/localize/new/${appframe.article.id}`, localizeMiddleware);
144
177
  _server.middlewares.use("/data/Logger/LogError", jsonParser);
@@ -155,6 +188,21 @@ export default function appframe() {
155
188
  _server.middlewares.use(createDevMiddleware(_server));
156
189
  };
157
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
+ },
158
206
  };
159
207
  }
160
208
  export { addAppframeBuildConfig, createDevMiddleware };
@@ -0,0 +1,63 @@
1
+ import type { Client } from "@olenbetong/appframe-data";
2
+ /**
3
+ * Format a file with Biome if available. Searches for `biome` in the nearest
4
+ * node_modules/.bin up from cwd, then falls back to the global PATH.
5
+ * Silently skips if Biome is not found or formatting fails.
6
+ */
7
+ export declare function formatWithBiome(filePath: string): Promise<void>;
8
+ export type CLIOptions = {
9
+ server: string;
10
+ types?: boolean;
11
+ global: boolean;
12
+ id: string;
13
+ fields: string | boolean;
14
+ permissions?: string;
15
+ maxRecords?: string;
16
+ sortOrder?: string;
17
+ master?: string;
18
+ linkFields?: string;
19
+ expose?: string | boolean;
20
+ dynamic: boolean;
21
+ unique?: string;
22
+ overrides?: string;
23
+ distinct?: boolean;
24
+ aggregates?: string;
25
+ groupBy?: string;
26
+ where?: string;
27
+ output?: string;
28
+ typesJsonParamOverrides?: Record<string, string>;
29
+ typesJsonReturnType?: string | null;
30
+ };
31
+ /**
32
+ * Serialize the generation options to a `/* af:config ... *\/` YAML comment block.
33
+ * List-type options (fields, sortOrder, etc.) are emitted as proper YAML lists.
34
+ */
35
+ export declare function buildYamlConfig(resource: string, options: CLIOptions): string;
36
+ /**
37
+ * Find and parse the `/* af:config ... *\/` block from generated file content.
38
+ * Returns `{ resource, options }` or `null` if no block is found.
39
+ */
40
+ export declare function parseYamlConfig(fileContent: string): {
41
+ resource: string;
42
+ options: CLIOptions;
43
+ } | null;
44
+ /**
45
+ * Compute the relative import specifier for "custom" from the given output file.
46
+ * Returns a POSIX-style path (e.g. "../custom" or "./custom").
47
+ */
48
+ export declare function getCustomImportPath(outputPath: string): string;
49
+ export declare function getProcedureDefinition(name: string, procDefinition: any, options: CLIOptions): string;
50
+ export declare function getDataObjectDefinition(name: string, viewDefinition: any, options: CLIOptions): string;
51
+ /**
52
+ * Fetch the resource definition from an authenticated Appframe client.
53
+ */
54
+ export declare function fetchResourceDefinition(client: Client, resourceName: string): Promise<any>;
55
+ /**
56
+ * Fetch the resource definition from the server, read types.json overrides, and
57
+ * return the generated TypeScript content string (without any header comment).
58
+ *
59
+ * @param resourceName - Pre-resolved resource database object ID (e.g. `aviw_QA_Documents`)
60
+ * @param options - Generation options
61
+ * @param client - Authenticated Appframe client
62
+ */
63
+ export declare function fetchAndGenerate(resourceName: string, options: CLIOptions, client: Client): Promise<string>;