@ynput/ayon-addon-dev 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +139 -0
- package/LICENSE +105 -0
- package/dist/browse.d.ts +59 -0
- package/dist/browse.js +156 -0
- package/dist/check.d.ts +30 -0
- package/dist/check.js +117 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +249 -0
- package/dist/eslint.d.ts +13 -0
- package/dist/eslint.js +47 -0
- package/dist/project.d.ts +28 -0
- package/dist/project.js +97 -0
- package/dist/rest.d.ts +47 -0
- package/dist/rest.js +181 -0
- package/dist/shell/index.js +1635 -0
- package/dist/shell/style.css +416 -0
- package/dist/standalone.d.ts +12 -0
- package/dist/standalone.js +176 -0
- package/dist/vite.d.ts +31 -0
- package/dist/vite.js +147 -0
- package/package.json +63 -4
- package/README.md +0 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ayon-addon: dev server and checks for AYON addon frontends. Every command prints JSON with --json.
|
|
3
|
+
// ayon-addon dev [--port 5174] the dev shell: the addon in AYON's surfaces, hot reload
|
|
4
|
+
// ayon-addon dev --ayon [--port 5173] watched build + server for AYON's dev override (?addonDev=)
|
|
5
|
+
// ayon-addon slots [--json] the slots of the installed @ynput/ayon-addon
|
|
6
|
+
// ayon-addon check [--json] [--no-types]
|
|
7
|
+
// ayon-addon open <slot> <id> the dev shell in a headless browser (live server)
|
|
8
|
+
// ayon-addon preview <slot> <id> --fixture <name> the same from a fixture, no server
|
|
9
|
+
// ayon-addon generate-rest the addon's server endpoints as RTK Query endpoints
|
|
10
|
+
import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
11
|
+
import { createServer } from 'http';
|
|
12
|
+
import path from 'path';
|
|
13
|
+
import { browse, formatBrowse } from './browse.js';
|
|
14
|
+
import { checkAddon, formatCheck } from './check.js';
|
|
15
|
+
import { findConnection, findPackageDir, importFromProject, readAddonProject } from './project.js';
|
|
16
|
+
import { generateRest } from './rest.js';
|
|
17
|
+
import { STANDALONE_ENV } from './standalone.js';
|
|
18
|
+
const argv = process.argv.slice(2);
|
|
19
|
+
const command = argv[0];
|
|
20
|
+
const flag = (name) => argv.includes(`--${name}`);
|
|
21
|
+
const option = (name, fallback) => {
|
|
22
|
+
const index = argv.indexOf(`--${name}`);
|
|
23
|
+
return index >= 0 ? argv[index + 1] : fallback;
|
|
24
|
+
};
|
|
25
|
+
const json = flag('json');
|
|
26
|
+
const print = (value, text) => console.log(json ? JSON.stringify(value, null, 2) : text);
|
|
27
|
+
const root = process.cwd();
|
|
28
|
+
const CONTENT_TYPES = {
|
|
29
|
+
'.js': 'text/javascript',
|
|
30
|
+
'.mjs': 'text/javascript',
|
|
31
|
+
'.css': 'text/css',
|
|
32
|
+
'.json': 'application/json',
|
|
33
|
+
'.map': 'application/json',
|
|
34
|
+
'.html': 'text/html',
|
|
35
|
+
'.svg': 'image/svg+xml',
|
|
36
|
+
'.png': 'image/png',
|
|
37
|
+
'.woff2': 'font/woff2',
|
|
38
|
+
};
|
|
39
|
+
// serves the build with CORS: AYON (any origin, also https) loads the remote from here
|
|
40
|
+
const serveDirectory = (dir, port) => createServer((request, response) => {
|
|
41
|
+
response.setHeader('Access-Control-Allow-Origin', '*');
|
|
42
|
+
// agents' headless browsers send x-api-key on every request, which makes these preflighted
|
|
43
|
+
response.setHeader('Access-Control-Allow-Headers', '*');
|
|
44
|
+
response.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
45
|
+
response.setHeader('Access-Control-Allow-Private-Network', 'true');
|
|
46
|
+
response.setHeader('Cache-Control', 'no-store');
|
|
47
|
+
if (request.method === 'OPTIONS')
|
|
48
|
+
return void response.writeHead(204).end();
|
|
49
|
+
const urlPath = decodeURIComponent((request.url || '/').split('?')[0]);
|
|
50
|
+
const file = path.join(dir, path.normalize(urlPath).replace(/^(\.\.[/\\])+/, ''));
|
|
51
|
+
if (!file.startsWith(dir) || !existsSync(file) || !statSync(file).isFile()) {
|
|
52
|
+
return void response.writeHead(404).end('not found');
|
|
53
|
+
}
|
|
54
|
+
response.writeHead(200, {
|
|
55
|
+
'Content-Type': CONTENT_TYPES[path.extname(file)] || 'application/octet-stream',
|
|
56
|
+
});
|
|
57
|
+
createReadStream(file).pipe(response);
|
|
58
|
+
}).listen(port);
|
|
59
|
+
// the build's dev info (dist/modules/<remote>/ayon-addon.json), written by ayonAddon()
|
|
60
|
+
const findDevInfo = () => {
|
|
61
|
+
const modules = path.join(root, 'dist', 'modules');
|
|
62
|
+
if (!existsSync(modules))
|
|
63
|
+
return undefined;
|
|
64
|
+
const file = readdirSync(modules)
|
|
65
|
+
.map((name) => path.join(modules, name, 'ayon-addon.json'))
|
|
66
|
+
.find((candidate) => existsSync(candidate));
|
|
67
|
+
return file && { dir: path.dirname(file), info: JSON.parse(readFileSync(file, 'utf8')) };
|
|
68
|
+
};
|
|
69
|
+
// the Vite dev server with the dev shell (ayonAddon()'s standalone plugin)
|
|
70
|
+
const devStandalone = async () => {
|
|
71
|
+
const project = readAddonProject(root);
|
|
72
|
+
const connection = findConnection(root);
|
|
73
|
+
const vite = await importFromProject(root, 'vite');
|
|
74
|
+
process.env[STANDALONE_ENV] = '1';
|
|
75
|
+
const server = await vite.createServer({
|
|
76
|
+
root,
|
|
77
|
+
server: { port: Number(option('port', '5174')) },
|
|
78
|
+
});
|
|
79
|
+
await server.listen();
|
|
80
|
+
const url = server.resolvedUrls.local[0];
|
|
81
|
+
print({ url, addon: project.name, server: connection.serverUrl, signedInWithKey: !!connection.apiKey }, [
|
|
82
|
+
`${project.name} ${project.version}: dev shell at ${url}`,
|
|
83
|
+
`AYON: ${connection.serverUrl}, ${connection.apiKey ? `signed in with the key from ${connection.apiKeySource}` : 'sign in on the page'}`,
|
|
84
|
+
'Preview without a server: ?preview=<fixture> (dev/fixtures/<fixture>.json)',
|
|
85
|
+
].join('\n'));
|
|
86
|
+
};
|
|
87
|
+
const dev = async () => {
|
|
88
|
+
// the dev shell is the default; --standalone is still accepted, as that default
|
|
89
|
+
if (!flag('ayon'))
|
|
90
|
+
return devStandalone();
|
|
91
|
+
const port = Number(option('port', '5173'));
|
|
92
|
+
const project = readAddonProject(root);
|
|
93
|
+
const vite = await importFromProject(root, 'vite');
|
|
94
|
+
process.env.AYON_ADDON_DEV = '1';
|
|
95
|
+
let served = false;
|
|
96
|
+
const watcher = (await vite.build({
|
|
97
|
+
root,
|
|
98
|
+
mode: 'development',
|
|
99
|
+
build: { watch: {} },
|
|
100
|
+
logLevel: 'warn',
|
|
101
|
+
}));
|
|
102
|
+
watcher.on('event', (event) => {
|
|
103
|
+
if (event.code === 'ERROR')
|
|
104
|
+
console.error(event.error);
|
|
105
|
+
if (event.code !== 'END')
|
|
106
|
+
return;
|
|
107
|
+
const built = findDevInfo();
|
|
108
|
+
if (!built) {
|
|
109
|
+
console.error('no ayon-addon.json in dist/modules: is ayonAddon() in vite.config.ts?');
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (served) {
|
|
113
|
+
if (!json)
|
|
114
|
+
console.log(`rebuilt (build ${built.info.buildId})`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
served = true;
|
|
118
|
+
serveDirectory(built.dir, port);
|
|
119
|
+
const url = `http://localhost:${port}`;
|
|
120
|
+
const open = `${findConnection(root).serverUrl}/?addonDev=${project.name}@${url}`;
|
|
121
|
+
print({ url, addon: project.name, remote: built.info.remote, buildId: built.info.buildId, open }, [
|
|
122
|
+
`${project.name} ${project.version}: built, serving ${url} (remote "${built.info.remote}")`,
|
|
123
|
+
'Open AYON with this addon from here (developer mode on):',
|
|
124
|
+
` ${open}`,
|
|
125
|
+
'The page reloads after every rebuild. ?addonDev=off goes back to the installed addon.',
|
|
126
|
+
].join('\n'));
|
|
127
|
+
});
|
|
128
|
+
};
|
|
129
|
+
const slots = async () => {
|
|
130
|
+
const pkgDir = findPackageDir(root, '@ynput/ayon-addon');
|
|
131
|
+
if (!pkgDir)
|
|
132
|
+
throw new Error('@ynput/ayon-addon is not installed here');
|
|
133
|
+
const data = JSON.parse(readFileSync(path.join(pkgDir, 'dist', 'slots.json'), 'utf8'));
|
|
134
|
+
const text = [
|
|
135
|
+
`@ynput/ayon-addon ${data.version}: ${data.slots.length} slots (details: node_modules/@ynput/ayon-addon/AGENTS.md)`,
|
|
136
|
+
...data.slots.map((s) => ` ${s.name.padEnd(30)} ${s.helper.padEnd(28)} ${s.loads.padEnd(18)} ${s.where}`),
|
|
137
|
+
].join('\n');
|
|
138
|
+
print(data, text);
|
|
139
|
+
};
|
|
140
|
+
const check = async () => {
|
|
141
|
+
const result = await checkAddon({ root, manifest: option('manifest'), types: !flag('no-types') });
|
|
142
|
+
print(result, formatCheck(result));
|
|
143
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
144
|
+
};
|
|
145
|
+
// ayonAddon({ rest }) in the addon's vite.config.ts
|
|
146
|
+
const readRestOptions = async () => {
|
|
147
|
+
const vite = await importFromProject(root, 'vite');
|
|
148
|
+
const loaded = await vite.loadConfigFromFile({ command: 'build', mode: 'production' }, undefined, root);
|
|
149
|
+
const flatten = async (plugins) => {
|
|
150
|
+
const value = await plugins;
|
|
151
|
+
return Array.isArray(value) ? (await Promise.all(value.map(flatten))).flat() : [value];
|
|
152
|
+
};
|
|
153
|
+
const plugin = (await flatten(loaded?.config.plugins ?? [])).find((item) => item?.name === 'ayon-addon');
|
|
154
|
+
return plugin?.api?.options?.rest ?? {};
|
|
155
|
+
};
|
|
156
|
+
const generateRestCommand = async () => {
|
|
157
|
+
const result = await generateRest({
|
|
158
|
+
root,
|
|
159
|
+
rest: await readRestOptions(),
|
|
160
|
+
schemaFile: option('schema'),
|
|
161
|
+
hooks: flag('hooks'),
|
|
162
|
+
interactive: !json && !!process.stdin.isTTY,
|
|
163
|
+
log: (line) => !json && console.log(line),
|
|
164
|
+
});
|
|
165
|
+
if (json)
|
|
166
|
+
print(result, '');
|
|
167
|
+
};
|
|
168
|
+
// the arguments that aren't flags or flag values
|
|
169
|
+
const BOOLEAN_FLAGS = ['--json', '--run', '--no-types', '--standalone', '--ayon', '--prune', '--hooks'];
|
|
170
|
+
const positional = () => {
|
|
171
|
+
const values = [];
|
|
172
|
+
for (let i = 1; i < argv.length; i++) {
|
|
173
|
+
if (!argv[i].startsWith('--'))
|
|
174
|
+
values.push(argv[i]);
|
|
175
|
+
else if (!BOOLEAN_FLAGS.includes(argv[i]))
|
|
176
|
+
i++;
|
|
177
|
+
}
|
|
178
|
+
return values;
|
|
179
|
+
};
|
|
180
|
+
const browseCommand = (mode) => async () => {
|
|
181
|
+
const [slot, id] = positional();
|
|
182
|
+
if (!slot || !id)
|
|
183
|
+
throw new Error(`usage: ayon-addon ${command} <slot> <id> (see ayon-addon help)`);
|
|
184
|
+
const fixture = option('fixture');
|
|
185
|
+
if (mode === 'preview' && !fixture)
|
|
186
|
+
throw new Error('preview needs --fixture <name>');
|
|
187
|
+
const result = await browse({
|
|
188
|
+
root,
|
|
189
|
+
slot,
|
|
190
|
+
id,
|
|
191
|
+
fixture: mode === 'preview' ? fixture : undefined,
|
|
192
|
+
project: option('project'),
|
|
193
|
+
entities: option('entities'),
|
|
194
|
+
page: option('page'),
|
|
195
|
+
url: option('url'),
|
|
196
|
+
screenshot: option('screenshot'),
|
|
197
|
+
run: flag('run'),
|
|
198
|
+
saveFixture: option('save-fixture'),
|
|
199
|
+
prune: flag('prune'),
|
|
200
|
+
timeout: option('timeout') ? Number(option('timeout')) : undefined,
|
|
201
|
+
});
|
|
202
|
+
print(result, formatBrowse(result));
|
|
203
|
+
process.exitCode = result.ok ? 0 : 1;
|
|
204
|
+
};
|
|
205
|
+
const HELP = `ayon-addon: tools for AYON addon frontends (run in the addon's frontend folder)
|
|
206
|
+
|
|
207
|
+
dev [--port 5174] the dev shell: the addon's contributions in AYON's surfaces,
|
|
208
|
+
without AYON, hot reload, a developer panel to pick slot,
|
|
209
|
+
project, entities
|
|
210
|
+
dev --ayon [--port 5173] build on every change and serve it for AYON's dev override:
|
|
211
|
+
open AYON with ?addonDev=<addon>@http://localhost:5173
|
|
212
|
+
slots [--json] the slots of the installed @ynput/ayon-addon
|
|
213
|
+
check [--json] [--no-types] the manifest as AYON reads it, the pieces' files, the types
|
|
214
|
+
open <slot> <id> [--project <name>] [--entities type:id,...] [--page overview|products|lists]
|
|
215
|
+
[--run] [--screenshot <file>] [--save-fixture <name>] [--url <dev shell>] [--json]
|
|
216
|
+
render one contribution in the dev shell, headless, and report
|
|
217
|
+
errors, toasts, its context (and the clipboard with --run)
|
|
218
|
+
preview <slot> <id> --fixture <name> [--run] [--prune] [--screenshot <file>] [--json]
|
|
219
|
+
the same from dev/fixtures/<name>.json, without a server;
|
|
220
|
+
--prune drops the answers the preview didn't use
|
|
221
|
+
generate-rest [--schema <file>] [--hooks] [--json]
|
|
222
|
+
the addon's server endpoints (its OpenAPI schema, downloaded
|
|
223
|
+
from a server with this version installed) as RTK Query
|
|
224
|
+
endpoints in AYON's api: src/api/generated/<name>.ts, never
|
|
225
|
+
edited. Creates src/api/<name>.ts to enhance them and export
|
|
226
|
+
their hooks, or offers to add the hooks of new endpoints
|
|
227
|
+
(--hooks: without asking). Options: ayonAddon({ rest })
|
|
228
|
+
|
|
229
|
+
Sign-in: AYON_SERVER_URL and AYON_API_KEY; in the AYON dev workbench, the local stack and
|
|
230
|
+
data/local-api-key.`;
|
|
231
|
+
const commands = {
|
|
232
|
+
dev,
|
|
233
|
+
slots,
|
|
234
|
+
check,
|
|
235
|
+
open: browseCommand('live'),
|
|
236
|
+
preview: browseCommand('preview'),
|
|
237
|
+
'generate-rest': generateRestCommand,
|
|
238
|
+
};
|
|
239
|
+
const run = commands[command];
|
|
240
|
+
if (!run) {
|
|
241
|
+
console.log(HELP);
|
|
242
|
+
process.exitCode = command && command !== 'help' ? 1 : 0;
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
run().catch((error) => {
|
|
246
|
+
console.error(json ? JSON.stringify({ ok: false, error: String(error) }) : error);
|
|
247
|
+
process.exitCode = 1;
|
|
248
|
+
});
|
|
249
|
+
}
|
package/dist/eslint.d.ts
ADDED
package/dist/eslint.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// Lint preset for addon frontends: imports that would bypass AYON's own copies.
|
|
2
|
+
// import { ayonAddonLint } from '@ynput/ayon-addon-dev/eslint'
|
|
3
|
+
// export default defineConfig([..., ...ayonAddonLint])
|
|
4
|
+
const viaAddonApi = (name, what) => ({
|
|
5
|
+
name,
|
|
6
|
+
message: `Import ${what} from @ynput/ayon-addon: it is AYON's copy, so it works in every slot.`,
|
|
7
|
+
});
|
|
8
|
+
export const ayonAddonLint = [
|
|
9
|
+
{
|
|
10
|
+
name: 'ayon-addon/imports',
|
|
11
|
+
files: ['**/*.{ts,tsx,js,jsx}'],
|
|
12
|
+
rules: {
|
|
13
|
+
'no-restricted-imports': [
|
|
14
|
+
'error',
|
|
15
|
+
{
|
|
16
|
+
paths: [
|
|
17
|
+
viaAddonApi('react-router-dom', 'the router hooks and Link'),
|
|
18
|
+
viaAddonApi('react-router', 'the router hooks and Link'),
|
|
19
|
+
viaAddonApi('react-toastify', 'toast'),
|
|
20
|
+
{
|
|
21
|
+
name: '@ynput/ayon-addon/host',
|
|
22
|
+
message: '@ynput/ayon-addon/host is for AYON itself, not for addons.',
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'ayon-addon/manifest',
|
|
31
|
+
files: ['**/manifest.{ts,tsx,js}'],
|
|
32
|
+
rules: {
|
|
33
|
+
'no-restricted-imports': [
|
|
34
|
+
'error',
|
|
35
|
+
{
|
|
36
|
+
paths: [
|
|
37
|
+
{
|
|
38
|
+
name: '@ynput/ayon-addon',
|
|
39
|
+
message: 'The manifest imports from @ynput/ayon-addon/manifest (bundled into the addon), so it loads on older AYON versions too.',
|
|
40
|
+
},
|
|
41
|
+
],
|
|
42
|
+
},
|
|
43
|
+
],
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
];
|
|
47
|
+
export default ayonAddonLint;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export interface AddonProject {
|
|
2
|
+
/** the frontend folder (where package.json and vite.config.ts are) */
|
|
3
|
+
root: string;
|
|
4
|
+
/** addon name and version from the addon's package.py (one folder up), else package.json */
|
|
5
|
+
name: string;
|
|
6
|
+
version: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const readAddonProject: (root?: string) => AddonProject;
|
|
9
|
+
export interface AyonConnection {
|
|
10
|
+
serverUrl: string;
|
|
11
|
+
/** sent as x-api-key: the app runs as that (service) user, without a login page */
|
|
12
|
+
apiKey?: string;
|
|
13
|
+
/** where the key came from, for messages */
|
|
14
|
+
apiKeySource?: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* AYON_SERVER_URL and AYON_API_KEY win; inside the AYON dev workbench the local stack and its
|
|
18
|
+
* key (data/local-api-key) are the defaults.
|
|
19
|
+
*/
|
|
20
|
+
export declare const findConnection: (root?: string) => AyonConnection;
|
|
21
|
+
/**
|
|
22
|
+
* Imports a package the way the addon resolves it, not from where this file lives: a linked
|
|
23
|
+
* @ynput/ayon-addon-dev would otherwise use the ayon-frontend checkout's copies.
|
|
24
|
+
*/
|
|
25
|
+
export declare const importFromProject: <T>(root: string, specifier: string) => Promise<T>;
|
|
26
|
+
/** Where a package is installed for the addon (node_modules lookup, no exports needed). */
|
|
27
|
+
export declare const findPackageDir: (root: string, pkgName: string) => string | undefined;
|
|
28
|
+
export declare const installedVersion: (root: string, pkgName: string) => string | undefined;
|
package/dist/project.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// What the tools need to know about the addon they run in: its name and version (package.py),
|
|
2
|
+
// where AYON runs, and how to sign in to it.
|
|
3
|
+
import { existsSync, readFileSync } from 'fs';
|
|
4
|
+
import { createRequire } from 'module';
|
|
5
|
+
import path from 'path';
|
|
6
|
+
import { pathToFileURL } from 'url';
|
|
7
|
+
const pyString = (source, key) => source.match(new RegExp(`^${key}\\s*=\\s*["']([^"']+)["']`, 'm'))?.[1];
|
|
8
|
+
export const readAddonProject = (root = process.cwd()) => {
|
|
9
|
+
const packagePy = path.resolve(root, '..', 'package.py');
|
|
10
|
+
if (existsSync(packagePy)) {
|
|
11
|
+
const source = readFileSync(packagePy, 'utf8');
|
|
12
|
+
const name = pyString(source, 'name');
|
|
13
|
+
const version = pyString(source, 'version');
|
|
14
|
+
if (name && version)
|
|
15
|
+
return { root, name, version };
|
|
16
|
+
}
|
|
17
|
+
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
18
|
+
return { root, name: pkg.name, version: pkg.version };
|
|
19
|
+
};
|
|
20
|
+
// the AYON dev workbench keeps the local stack's service key in data/local-api-key
|
|
21
|
+
const findWorkbench = (from) => {
|
|
22
|
+
let dir = path.resolve(from);
|
|
23
|
+
while (dir !== path.dirname(dir)) {
|
|
24
|
+
if (existsSync(path.join(dir, 'data', 'local-api-key')) && existsSync(path.join(dir, 'mani.yaml')))
|
|
25
|
+
return dir;
|
|
26
|
+
dir = path.dirname(dir);
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
};
|
|
30
|
+
const workbenchServerPort = (workbench) => {
|
|
31
|
+
const env = path.join(workbench, '.env');
|
|
32
|
+
const port = existsSync(env) && readFileSync(env, 'utf8').match(/^SERVER_PORT=(\d+)/m)?.[1];
|
|
33
|
+
return port || '5100';
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* AYON_SERVER_URL and AYON_API_KEY win; inside the AYON dev workbench the local stack and its
|
|
37
|
+
* key (data/local-api-key) are the defaults.
|
|
38
|
+
*/
|
|
39
|
+
export const findConnection = (root = process.cwd()) => {
|
|
40
|
+
const workbench = findWorkbench(root);
|
|
41
|
+
const serverUrl = process.env.AYON_SERVER_URL ||
|
|
42
|
+
process.env.SERVER_URL ||
|
|
43
|
+
(workbench ? `http://localhost:${workbenchServerPort(workbench)}` : 'http://localhost:5000');
|
|
44
|
+
if (process.env.AYON_API_KEY) {
|
|
45
|
+
return { serverUrl, apiKey: process.env.AYON_API_KEY, apiKeySource: 'AYON_API_KEY' };
|
|
46
|
+
}
|
|
47
|
+
// another server named explicitly: never send it the local stack's key
|
|
48
|
+
if (workbench && !process.env.AYON_SERVER_URL && !process.env.SERVER_URL) {
|
|
49
|
+
const keyFile = path.join(workbench, 'data', 'local-api-key');
|
|
50
|
+
return { serverUrl, apiKey: readFileSync(keyFile, 'utf8').trim(), apiKeySource: keyFile };
|
|
51
|
+
}
|
|
52
|
+
return { serverUrl };
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Imports a package the way the addon resolves it, not from where this file lives: a linked
|
|
56
|
+
* @ynput/ayon-addon-dev would otherwise use the ayon-frontend checkout's copies.
|
|
57
|
+
*/
|
|
58
|
+
export const importFromProject = async (root, specifier) => {
|
|
59
|
+
const require = createRequire(path.join(root, 'package.json'));
|
|
60
|
+
let resolved;
|
|
61
|
+
try {
|
|
62
|
+
resolved = require.resolve(specifier);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// ESM-only packages: read their package.json
|
|
66
|
+
const [scope, name, ...rest] = specifier.split('/');
|
|
67
|
+
const pkgName = scope.startsWith('@') ? `${scope}/${name}` : scope;
|
|
68
|
+
const sub = (scope.startsWith('@') ? rest : [name, ...rest]).filter(Boolean).join('/');
|
|
69
|
+
let dir = root;
|
|
70
|
+
while (!existsSync(path.join(dir, 'node_modules', pkgName, 'package.json'))) {
|
|
71
|
+
if (dir === path.dirname(dir))
|
|
72
|
+
throw new Error(`${specifier} is not installed in ${root}`);
|
|
73
|
+
dir = path.dirname(dir);
|
|
74
|
+
}
|
|
75
|
+
const pkgDir = path.join(dir, 'node_modules', pkgName);
|
|
76
|
+
const pkg = JSON.parse(readFileSync(path.join(pkgDir, 'package.json'), 'utf8'));
|
|
77
|
+
const entry = pkg.exports?.[sub ? `./${sub}` : '.'];
|
|
78
|
+
const target = typeof entry === 'string' ? entry : entry?.import ?? entry?.default ?? pkg.module ?? pkg.main;
|
|
79
|
+
resolved = path.join(pkgDir, target);
|
|
80
|
+
}
|
|
81
|
+
return import(pathToFileURL(resolved).href);
|
|
82
|
+
};
|
|
83
|
+
/** Where a package is installed for the addon (node_modules lookup, no exports needed). */
|
|
84
|
+
export const findPackageDir = (root, pkgName) => {
|
|
85
|
+
let dir = root;
|
|
86
|
+
while (dir !== path.dirname(dir)) {
|
|
87
|
+
const candidate = path.join(dir, 'node_modules', pkgName);
|
|
88
|
+
if (existsSync(path.join(candidate, 'package.json')))
|
|
89
|
+
return candidate;
|
|
90
|
+
dir = path.dirname(dir);
|
|
91
|
+
}
|
|
92
|
+
return undefined;
|
|
93
|
+
};
|
|
94
|
+
export const installedVersion = (root, pkgName) => {
|
|
95
|
+
const dir = findPackageDir(root, pkgName);
|
|
96
|
+
return dir && JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8')).version;
|
|
97
|
+
};
|
package/dist/rest.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** `rest` in ayonAddon(): where `ayon-addon generate-rest` writes, and what it can't tell itself. */
|
|
2
|
+
export interface RestOptions {
|
|
3
|
+
/** the generated endpoints, never edited (default src/api/generated/<name>.ts) */
|
|
4
|
+
generated?: string;
|
|
5
|
+
/** the file that enhances them and exports their hooks (default src/api/<name>.ts) */
|
|
6
|
+
api?: string;
|
|
7
|
+
/** the module whose default export is the addon version (default src/addonVersion.ts) */
|
|
8
|
+
versionModule?: string;
|
|
9
|
+
/** the downloaded schema (default gen/openapi.json) */
|
|
10
|
+
schema?: string;
|
|
11
|
+
/** endpoints that are POST but only read, generated as queries: `timetrackerQueryTimeLogs` */
|
|
12
|
+
queries?: string[];
|
|
13
|
+
}
|
|
14
|
+
export interface GenerateRestOptions {
|
|
15
|
+
root: string;
|
|
16
|
+
rest: RestOptions;
|
|
17
|
+
/** generate from this schema file instead of downloading it */
|
|
18
|
+
schemaFile?: string;
|
|
19
|
+
/** add missing hooks without asking */
|
|
20
|
+
hooks?: boolean;
|
|
21
|
+
/** may ask in the terminal */
|
|
22
|
+
interactive: boolean;
|
|
23
|
+
log: (line: string) => void;
|
|
24
|
+
}
|
|
25
|
+
export interface GenerateRestResult {
|
|
26
|
+
url?: string;
|
|
27
|
+
schema: string;
|
|
28
|
+
generated: string;
|
|
29
|
+
api: string;
|
|
30
|
+
endpoints: {
|
|
31
|
+
name: string;
|
|
32
|
+
type: 'query' | 'mutation';
|
|
33
|
+
}[];
|
|
34
|
+
/** the enhanced api file was created */
|
|
35
|
+
created: boolean;
|
|
36
|
+
/** hooks added to the enhanced api file */
|
|
37
|
+
addedHooks: string[];
|
|
38
|
+
/** hooks of endpoints the enhanced api file doesn't export */
|
|
39
|
+
missingHooks: string[];
|
|
40
|
+
/** hooks the enhanced api file exports for endpoints that are gone */
|
|
41
|
+
staleHooks: string[];
|
|
42
|
+
}
|
|
43
|
+
export declare const hookName: ({ name, type }: {
|
|
44
|
+
name: string;
|
|
45
|
+
type: "query" | "mutation";
|
|
46
|
+
}) => string;
|
|
47
|
+
export declare const generateRest: (options: GenerateRestOptions) => Promise<GenerateRestResult>;
|
package/dist/rest.js
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// `ayon-addon generate-rest`: the addon's server endpoints as RTK Query endpoints injected into
|
|
2
|
+
// AYON's api (they share its cache), from the OpenAPI schema of the installed addon.
|
|
3
|
+
// 1. downloads /api/addons/<name>/<version>/openapi.json (AYON's own /openapi.json has no addon
|
|
4
|
+
// routes) to gen/openapi.json, with operation ids prefixed by the addon name
|
|
5
|
+
// 2. generates the endpoints with @rtk-query/codegen-openapi, without hooks
|
|
6
|
+
// 3. puts addonVersion in the URLs, so they call the version that is running
|
|
7
|
+
// 4. creates the file that enhances them (tags, optimistic updates) and exports their hooks, or
|
|
8
|
+
// adds the hooks of new endpoints to it. The generated file is never edited by hand.
|
|
9
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import { createInterface } from 'readline/promises';
|
|
12
|
+
import { findConnection, importFromProject, readAddonProject } from './project.js';
|
|
13
|
+
const camel = (name) => name.replace(/[^A-Za-z0-9]+(.)?/g, (_, next) => (next ? next.toUpperCase() : ''));
|
|
14
|
+
const capitalize = (name) => name.charAt(0).toUpperCase() + name.slice(1);
|
|
15
|
+
const posixRelative = (from, to) => {
|
|
16
|
+
const relative = path.relative(path.dirname(from), to).replace(/\\/g, '/').replace(/\.tsx?$/, '');
|
|
17
|
+
return relative.startsWith('.') ? relative : `./${relative}`;
|
|
18
|
+
};
|
|
19
|
+
export const hookName = ({ name, type }) => `use${capitalize(name)}${type === 'query' ? 'Query' : 'Mutation'}`;
|
|
20
|
+
const downloadSchema = async (root, addon) => {
|
|
21
|
+
const { serverUrl, apiKey } = findConnection(root);
|
|
22
|
+
const url = `${serverUrl}/api/addons/${addon.name}/${addon.version}/openapi.json`;
|
|
23
|
+
const response = await fetch(url, { headers: apiKey ? { 'x-api-key': apiKey } : {} });
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
throw new Error(`${url} answered ${response.status}: is ${addon.name} ${addon.version} installed there?`);
|
|
26
|
+
}
|
|
27
|
+
return { url, schema: (await response.json()) };
|
|
28
|
+
};
|
|
29
|
+
// Every addon injects into AYON's one api: prefixed operation ids (timetrackerStartSession) can't
|
|
30
|
+
// clash with AYON's or another addon's endpoints.
|
|
31
|
+
const patchSchema = (schema, prefix) => {
|
|
32
|
+
for (const operations of Object.values(schema.paths)) {
|
|
33
|
+
for (const operation of Object.values(operations)) {
|
|
34
|
+
if (operation.operationId && !operation.operationId.startsWith(`${prefix}_`))
|
|
35
|
+
operation.operationId = `${prefix}_${operation.operationId}`;
|
|
36
|
+
// optional headers are typed string | null, which fetch's headers don't take: a header is
|
|
37
|
+
// either sent or not (AYON's own schema types them as string)
|
|
38
|
+
for (const parameter of operation.parameters ?? []) {
|
|
39
|
+
const types = parameter.in === 'header' ? parameter.schema?.anyOf : undefined;
|
|
40
|
+
if (types?.some((type) => type.type === 'null')) {
|
|
41
|
+
const rest = { ...parameter.schema };
|
|
42
|
+
delete rest.anyOf;
|
|
43
|
+
parameter.schema = { ...rest, ...types.find((type) => type.type !== 'null') };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return schema;
|
|
49
|
+
};
|
|
50
|
+
const readEndpoints = (source) => [...source.matchAll(/^\s*(\w+): build\.(query|mutation)</gm)].map(([, name, type]) => ({
|
|
51
|
+
name,
|
|
52
|
+
type: type,
|
|
53
|
+
}));
|
|
54
|
+
const apiFileContent = (addonName, apiExport, generatedExport, generatedImport, hooks) => `// ${addonName}'s server endpoints. They are generated into ${generatedImport}.ts by
|
|
55
|
+
// \`ayon-addon generate-rest\`: never edit that file, change them here instead (cache tags,
|
|
56
|
+
// transformResponse, optimistic updates). generate-rest offers to add the hooks of new endpoints.
|
|
57
|
+
import { ${generatedExport} } from "${generatedImport}";
|
|
58
|
+
|
|
59
|
+
export const ${apiExport} = ${generatedExport}.enhanceEndpoints({
|
|
60
|
+
addTagTypes: [],
|
|
61
|
+
endpoints: {},
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
export const {
|
|
65
|
+
${hooks.map((hook) => ` ${hook},`).join('\n')}
|
|
66
|
+
} = ${apiExport};
|
|
67
|
+
`;
|
|
68
|
+
const confirm = async (question) => {
|
|
69
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
70
|
+
try {
|
|
71
|
+
const answer = (await rl.question(`${question} (Y/n) `)).trim().toLowerCase();
|
|
72
|
+
return answer === '' || answer === 'y' || answer === 'yes';
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
rl.close();
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
export const generateRest = async (options) => {
|
|
79
|
+
const { root, rest, log } = options;
|
|
80
|
+
const addon = readAddonProject(root);
|
|
81
|
+
const id = camel(addon.name);
|
|
82
|
+
const resolve = (file) => path.resolve(root, file);
|
|
83
|
+
const generated = resolve(rest.generated || `src/api/generated/${addon.name}.ts`);
|
|
84
|
+
const apiFile = resolve(rest.api || `src/api/${addon.name}.ts`);
|
|
85
|
+
const versionModule = resolve(rest.versionModule || 'src/addonVersion.ts');
|
|
86
|
+
const generatedExport = `${id}RestApi`;
|
|
87
|
+
const apiExport = `${id}Api`;
|
|
88
|
+
// 1. the schema
|
|
89
|
+
let schema = resolve(options.schemaFile || rest.schema || 'gen/openapi.json');
|
|
90
|
+
let url;
|
|
91
|
+
if (!options.schemaFile) {
|
|
92
|
+
const download = await downloadSchema(root, addon);
|
|
93
|
+
url = download.url;
|
|
94
|
+
mkdirSync(path.dirname(schema), { recursive: true });
|
|
95
|
+
writeFileSync(schema, JSON.stringify(patchSchema(download.schema, addon.name), null, 2));
|
|
96
|
+
log(`${path.relative(root, schema)}: ${Object.keys(download.schema.paths).length} paths from ${url}`);
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
schema = resolve(options.schemaFile);
|
|
100
|
+
const patched = patchSchema(JSON.parse(readFileSync(schema, 'utf8')), addon.name);
|
|
101
|
+
writeFileSync(schema, JSON.stringify(patched, null, 2));
|
|
102
|
+
}
|
|
103
|
+
// 2. the endpoints
|
|
104
|
+
const { generateEndpoints } = await importFromProject(root, '@rtk-query/codegen-openapi').catch(() => {
|
|
105
|
+
throw new Error('generate-rest needs @rtk-query/codegen-openapi: yarn add -D @rtk-query/codegen-openapi');
|
|
106
|
+
});
|
|
107
|
+
const queries = new Set(rest.queries ?? []);
|
|
108
|
+
mkdirSync(path.dirname(generated), { recursive: true });
|
|
109
|
+
await generateEndpoints({
|
|
110
|
+
schemaFile: schema,
|
|
111
|
+
apiFile: '@ynput/ayon-frontend-shared/api',
|
|
112
|
+
apiImport: 'api',
|
|
113
|
+
exportName: generatedExport,
|
|
114
|
+
outputFile: generated,
|
|
115
|
+
hooks: false,
|
|
116
|
+
// the addon's endpoints only
|
|
117
|
+
filterEndpoints: (_name, operation) => operation.path.startsWith(`/api/addons/${addon.name}/`),
|
|
118
|
+
endpointOverrides: queries.size ? [{ pattern: [...queries], type: 'query' }] : undefined,
|
|
119
|
+
});
|
|
120
|
+
// 3. the running version in the URLs
|
|
121
|
+
let source = readFileSync(generated, 'utf8');
|
|
122
|
+
source = source.replace(new RegExp(`/api/addons/${addon.name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/[^/\`]+/`, 'g'), `/api/addons/${addon.name}/\${addonVersion}/`);
|
|
123
|
+
source =
|
|
124
|
+
`// Generated by \`ayon-addon generate-rest\` from the addon's OpenAPI schema. Do not edit:\n` +
|
|
125
|
+
`// enhance the endpoints in ${posixRelative(generated, apiFile)}.ts instead.\n` +
|
|
126
|
+
`import addonVersion from "${posixRelative(generated, versionModule)}";\n` +
|
|
127
|
+
source;
|
|
128
|
+
writeFileSync(generated, source);
|
|
129
|
+
const endpoints = readEndpoints(source);
|
|
130
|
+
log(`${path.relative(root, generated)}: ${endpoints.length} endpoints`);
|
|
131
|
+
// 4. the enhanced api and its hooks
|
|
132
|
+
const hooks = endpoints.map(hookName);
|
|
133
|
+
const result = {
|
|
134
|
+
url,
|
|
135
|
+
schema,
|
|
136
|
+
generated,
|
|
137
|
+
api: apiFile,
|
|
138
|
+
endpoints,
|
|
139
|
+
created: false,
|
|
140
|
+
addedHooks: [],
|
|
141
|
+
missingHooks: [],
|
|
142
|
+
staleHooks: [],
|
|
143
|
+
};
|
|
144
|
+
if (!existsSync(apiFile)) {
|
|
145
|
+
mkdirSync(path.dirname(apiFile), { recursive: true });
|
|
146
|
+
writeFileSync(apiFile, apiFileContent(addon.name, apiExport, generatedExport, posixRelative(apiFile, generated), hooks));
|
|
147
|
+
result.created = true;
|
|
148
|
+
result.addedHooks = hooks;
|
|
149
|
+
log(`${path.relative(root, apiFile)}: created, enhance the endpoints there`);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
const apiSource = readFileSync(apiFile, 'utf8');
|
|
153
|
+
const exported = (hook) => new RegExp(`\\b${hook}\\b`).test(apiSource);
|
|
154
|
+
result.missingHooks = hooks.filter((hook) => !exported(hook));
|
|
155
|
+
const known = new Set(hooks);
|
|
156
|
+
result.staleHooks = [...new Set(apiSource.match(new RegExp(`\\buse${capitalize(id)}\\w+(Query|Mutation)\\b`, 'g')))]
|
|
157
|
+
.filter((hook) => !hook.startsWith('useLazy') && !known.has(hook));
|
|
158
|
+
if (result.staleHooks.length) {
|
|
159
|
+
log(`${path.relative(root, apiFile)} exports hooks of endpoints that are gone: ${result.staleHooks.join(', ')}`);
|
|
160
|
+
}
|
|
161
|
+
if (!result.missingHooks.length)
|
|
162
|
+
return result;
|
|
163
|
+
const relative = path.relative(root, apiFile);
|
|
164
|
+
const add = options.hooks ||
|
|
165
|
+
(options.interactive &&
|
|
166
|
+
(await confirm(`Export hooks for ${result.missingHooks.length} new endpoints in ${relative}?`)));
|
|
167
|
+
if (!add) {
|
|
168
|
+
log(`${relative} doesn't export: ${result.missingHooks.join(', ')} (add them with --hooks)`);
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
// a block of its own: the user's exports are left as they are
|
|
172
|
+
const apiName = apiSource.match(new RegExp(`export const (\\w+) = ${generatedExport}\\b`))?.[1] || apiExport;
|
|
173
|
+
const block = `\n// added by ayon-addon generate-rest\nexport const {\n${result.missingHooks
|
|
174
|
+
.map((hook) => ` ${hook},`)
|
|
175
|
+
.join('\n')}\n} = ${apiName};\n`;
|
|
176
|
+
writeFileSync(apiFile, apiSource.replace(/\n*$/, '\n') + block);
|
|
177
|
+
result.addedHooks = result.missingHooks;
|
|
178
|
+
result.missingHooks = [];
|
|
179
|
+
log(`${relative}: exported ${result.addedHooks.join(', ')}`);
|
|
180
|
+
return result;
|
|
181
|
+
};
|