asphodelos 0.0.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.
@@ -0,0 +1,336 @@
1
+ import { parseConfig } from "../index.mjs";
2
+ import { t as FormatOptions } from "../format-B0W7HIAg.mjs";
3
+ import { b as fileSystemLayer } from "../core-DS3sAabb.mjs";
4
+ import { n as parseOpenAPI } from "../openapi-B4aAnx4P.mjs";
5
+ import { i as makeJob, n as isUserCodeJob, r as jobTargets, t as cleanSplitOutputs } from "../shared-CrbXsFUJ.mjs";
6
+ import { Effect, FileSystem, Result } from "effect";
7
+ import path from "node:path";
8
+ import crypto from "node:crypto";
9
+ //#region src/vite-plugin/index.ts
10
+ /** The config file the plugin reads, from the directory Vite was started in. */
11
+ const CONFIG_FILE = "asphodelos.config.ts";
12
+ /** Extensions a change has to carry to be worth regenerating for โ€” the set `--watch` reacts to. */
13
+ const INPUT_EXTENSIONS = [
14
+ ".yaml",
15
+ ".json",
16
+ ".tsp"
17
+ ];
18
+ /**
19
+ * How long a burst of filesystem events is let settle before a pass runs.
20
+ *
21
+ * An editor emits several events per save, and a batch change (a `git checkout`) emits one per
22
+ * file; a pass per event would race itself.
23
+ */
24
+ const DEBOUNCE_MS = 200;
25
+ /** A `(value) => void` that runs `callback` once the calls stop, with the last value passed. */
26
+ function debounce(delayMilliseconds, callback) {
27
+ const pending = {};
28
+ return (value) => {
29
+ clearTimeout(pending.timer);
30
+ pending.timer = setTimeout(() => {
31
+ callback(value);
32
+ }, delayMilliseconds);
33
+ };
34
+ }
35
+ /** Runs a filesystem Effect at the plugin's boundary, where Vite hands over and waits on Promises. */
36
+ function runWithFileSystem(effect) {
37
+ return Effect.runPromise(effect.pipe(Effect.provide(fileSystemLayer)));
38
+ }
39
+ function messageOf(error) {
40
+ return error instanceof Error ? error.message : String(error);
41
+ }
42
+ /**
43
+ * Loads and validates the config through Vite's own module loader.
44
+ *
45
+ * `ssrLoadModule` rather than the CLI's `readConfig`: Vite already transpiles TypeScript and
46
+ * resolves the config's imports the way the rest of the project sees them, and invalidating the
47
+ * module is how an edit is picked up. Every failure comes back as the sentence to print.
48
+ */
49
+ function loadConfig(server, configPath) {
50
+ return Effect.gen(function* () {
51
+ if (!(yield* (yield* FileSystem.FileSystem).exists(configPath).pipe(Effect.orElseSucceed(() => false)))) return yield* Effect.fail(`Config not found: ${configPath}`);
52
+ const loaded = yield* Effect.tryPromise({
53
+ try: async () => {
54
+ const resolved = await server.pluginContainer.resolveId(configPath);
55
+ if (resolved) {
56
+ const moduleNode = server.moduleGraph.getModuleById(resolved.id);
57
+ if (moduleNode) server.moduleGraph.invalidateModule(moduleNode);
58
+ } else server.moduleGraph.invalidateAll();
59
+ return server.ssrLoadModule(`${configPath}?t=${String(Date.now())}`);
60
+ },
61
+ catch: messageOf
62
+ });
63
+ const defaultExport = typeof loaded === "object" && loaded !== null ? Reflect.get(loaded, "default") : void 0;
64
+ if (typeof defaultExport !== "object" || defaultExport === null) return yield* Effect.fail("Config must export default object");
65
+ return yield* parseConfig(defaultExport).pipe(Effect.mapError((error) => error.message));
66
+ });
67
+ }
68
+ /**
69
+ * `stat`, or `null` when the path cannot be read.
70
+ *
71
+ * Every filesystem question the plugin asks on its own account is advisory โ€” whether to skip a
72
+ * pass, whether to reload, what to clean up โ€” never whether the output is valid. So a path it
73
+ * cannot see reads as absent, and the dev server keeps running.
74
+ */
75
+ function statOrNull(target) {
76
+ return Effect.gen(function* () {
77
+ return yield* (yield* FileSystem.FileSystem).stat(target).pipe(Effect.orElseSucceed(() => null));
78
+ });
79
+ }
80
+ /** Directory entries with each one's kind; `readDirectory` answers with names only. */
81
+ function readEntries(directory) {
82
+ return Effect.gen(function* () {
83
+ const names = yield* (yield* FileSystem.FileSystem).readDirectory(directory).pipe(Effect.orElseSucceed(() => []));
84
+ const paths = names.map((name) => path.join(directory, name));
85
+ const infos = yield* Effect.all(paths.map(statOrNull), { concurrency: "unbounded" });
86
+ return names.map((name, index) => ({
87
+ name,
88
+ path: paths[index] ?? path.join(directory, name),
89
+ type: infos[index]?.type
90
+ }));
91
+ });
92
+ }
93
+ /** Neither installed packages nor dot-directories (`.git`, caches) hold the project's documents. */
94
+ function isSkippedDirectory(name) {
95
+ return name === "node_modules" || name.startsWith(".");
96
+ }
97
+ function isInputFile(filePath) {
98
+ return INPUT_EXTENSIONS.some((extension) => filePath.endsWith(extension));
99
+ }
100
+ /**
101
+ * Whether a change under `directory` is one to the documents the config reads.
102
+ *
103
+ * `path.relative` rather than `startsWith`: `/api` is a string prefix of `/api-old/spec.yaml`
104
+ * without being its directory.
105
+ */
106
+ function isWatchedInput(directory, filePath) {
107
+ const relative = path.relative(directory, filePath);
108
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative) && isInputFile(relative) && !relative.split(path.sep).slice(0, -1).some(isSkippedDirectory);
109
+ }
110
+ /** Every file under `target` (itself, when it is one), skipping what {@link isSkippedDirectory} does. */
111
+ function listFiles(target) {
112
+ return Effect.gen(function* () {
113
+ const info = yield* statOrNull(target);
114
+ if (info?.type === "File") return [target];
115
+ if (info?.type !== "Directory") return [];
116
+ const entries = yield* readEntries(target);
117
+ return (yield* Effect.all(entries.filter((entry) => entry.type === "File" || !isSkippedDirectory(entry.name)).map((entry) => listFiles(entry.path)), { concurrency: "unbounded" })).flat();
118
+ });
119
+ }
120
+ /**
121
+ * A digest of every document under the input directory, or `null` when the set cannot be read
122
+ * reliably โ€” which callers treat as "changed" and regenerate.
123
+ *
124
+ * The whole directory rather than the file `input` names: a TypeSpec entry imports its siblings
125
+ * and a `$ref` can point at one, so the named file is rarely the only one that matters.
126
+ */
127
+ function hashInputs(directory) {
128
+ return Effect.gen(function* () {
129
+ const fs = yield* FileSystem.FileSystem;
130
+ const files = (yield* listFiles(directory)).filter(isInputFile).toSorted();
131
+ if (files.length === 0) return null;
132
+ const contents = yield* Effect.all(files.map((file) => fs.readFileString(file).pipe(Effect.orElseSucceed(() => null))), { concurrency: "unbounded" });
133
+ if (contents.includes(null)) return null;
134
+ const hash = crypto.createHash("sha256");
135
+ for (const [index, file] of files.entries()) {
136
+ hash.update(file);
137
+ hash.update("\0");
138
+ hash.update(contents[index] ?? "");
139
+ hash.update("\0");
140
+ }
141
+ return hash.digest("hex");
142
+ });
143
+ }
144
+ /**
145
+ * The content digest of every file under `targets`, keyed by path.
146
+ *
147
+ * Content rather than mtime: the split clean deletes files the same pass writes back byte for
148
+ * byte, and a rewrite with nothing new in it is no reason to reload the browser. It also does not
149
+ * depend on how finely the filesystem records time.
150
+ */
151
+ function snapshotOutputs(targets) {
152
+ return Effect.gen(function* () {
153
+ const fs = yield* FileSystem.FileSystem;
154
+ const listed = yield* Effect.all(targets.map(listFiles), { concurrency: "unbounded" });
155
+ const files = [...new Set(listed.flat())];
156
+ const digests = yield* Effect.all(files.map((file) => fs.readFileString(file).pipe(Effect.map((content) => crypto.createHash("sha256").update(content).digest("hex")), Effect.orElseSucceed(() => null))), { concurrency: "unbounded" });
157
+ return new Map(files.map((file, index) => [file, digests[index] ?? null]));
158
+ });
159
+ }
160
+ function isSameSnapshot(before, after) {
161
+ return before.size === after.size && [...before].every(([file, digest]) => after.has(file) && after.get(file) === digest);
162
+ }
163
+ /** Whether every output the last successful pass wrote is still on disk. */
164
+ function hasAllOutputs(jobs) {
165
+ return Effect.gen(function* () {
166
+ return (yield* Effect.all(jobs.map((job) => statOrNull(path.resolve(process.cwd(), job.output))), { concurrency: "unbounded" })).every((info) => info !== null);
167
+ });
168
+ }
169
+ /** The `.ts` files one stale output leaves to remove: itself, or a split directory's children. */
170
+ function removeStaleOutput(output, keep) {
171
+ return Effect.gen(function* () {
172
+ const fs = yield* FileSystem.FileSystem;
173
+ const info = yield* statOrNull(output);
174
+ const removable = (info?.type === "Directory" ? (yield* readEntries(output)).filter((entry) => entry.type === "File" && entry.name.endsWith(".ts")).map((entry) => entry.path) : info?.type === "File" && output.endsWith(".ts") ? [output] : []).filter((file) => !keep.has(file) && !keep.has(path.dirname(file)));
175
+ return (yield* Effect.all(removable.map((file) => fs.remove(file, { force: true }).pipe(Effect.as([file]), Effect.orElseSucceed(() => []))), { concurrency: "unbounded" })).flat();
176
+ });
177
+ }
178
+ /**
179
+ * Removes what the previous pass generated and this one no longer does, answering with what went.
180
+ *
181
+ * A config edit that repoints or drops an output, or a document that no longer has a section,
182
+ * would otherwise leave the old file behind, still importing names that are gone. Deliberately
183
+ * narrower than a recursive delete, because a path the config used to name may be shared with the
184
+ * user:
185
+ *
186
+ * - outputs that merge into the user's code (`elysia`, `test`) are never removed;
187
+ * - a stale file is removed only when it is a `.ts` file;
188
+ * - a stale split directory loses only its direct `.ts` children, never a subdirectory, and the
189
+ * directory itself stays;
190
+ * - nothing the current pass writes is touched, nor anything directly inside a directory it
191
+ * writes into.
192
+ */
193
+ function removeStaleOutputs(previous, current) {
194
+ return Effect.gen(function* () {
195
+ const keep = new Set(current.flatMap(jobTargets));
196
+ const stale = new Set(previous.filter((job) => !isUserCodeJob(job)).map((job) => path.resolve(process.cwd(), job.output)).filter((output) => !keep.has(output)));
197
+ return (yield* Effect.all([...stale].map((output) => removeStaleOutput(output, keep)), { concurrency: "unbounded" })).flat();
198
+ });
199
+ }
200
+ /**
201
+ * One generation pass: parse the document, clean the split outputs, run every job and say whether
202
+ * anything the browser loads changed.
203
+ *
204
+ * `Effect.result` per job is what keeps a failure from cancelling its siblings โ€” a dev server
205
+ * keeps running either way โ€” so the logs that come back are one line per job. `jobs` is absent
206
+ * when the document did not parse, and then nothing was written.
207
+ */
208
+ function generate(config) {
209
+ return Effect.gen(function* () {
210
+ const parsed = yield* Effect.result(parseOpenAPI(config.input));
211
+ if (Result.isFailure(parsed)) return {
212
+ logs: [`โŒ parseOpenAPI: ${parsed.failure.message}`],
213
+ changed: false,
214
+ jobs: void 0
215
+ };
216
+ const jobs = makeJob(parsed.success, config);
217
+ const targets = jobs.flatMap(jobTargets);
218
+ const before = yield* snapshotOutputs(targets);
219
+ const cleaned = yield* Effect.result(cleanSplitOutputs(jobs));
220
+ const cleanLogs = Result.isFailure(cleaned) ? [`โŒ clean: ${cleaned.failure.message}`] : [];
221
+ const jobLogs = yield* Effect.all(jobs.map((job) => Effect.result(job.run(job.output)).pipe(Effect.map((result) => Result.isSuccess(result) ? `โœ… ${job.name}${job.split ? " (split)" : ""} -> ${job.output}` : `โŒ ${job.name}: ${result.failure.message}`))), { concurrency: "unbounded" }).pipe(Effect.provideService(FormatOptions, config.format ?? {}));
222
+ const after = yield* snapshotOutputs(targets);
223
+ return {
224
+ logs: [...cleanLogs, ...jobLogs],
225
+ changed: !isSameSnapshot(before, after),
226
+ jobs
227
+ };
228
+ });
229
+ }
230
+ /**
231
+ * The Vite plugin: regenerates on every change to `asphodelos.config.ts` or to the documents it
232
+ * names, and reloads the browser when the output actually changed.
233
+ *
234
+ * Every pass โ€” the first one, a config edit, a document edit โ€” goes through one queue, so no two
235
+ * passes ever interleave their cleanup with each other's writes.
236
+ */
237
+ function asphodelosVite() {
238
+ const configPath = path.resolve(process.cwd(), CONFIG_FILE);
239
+ const state = {
240
+ config: null,
241
+ inputDirectory: null,
242
+ inputHash: null,
243
+ jobs: null,
244
+ queue: Promise.resolve()
245
+ };
246
+ const enqueue = (task) => {
247
+ const previous = state.queue;
248
+ const queued = (async () => {
249
+ await previous;
250
+ try {
251
+ await task();
252
+ } catch (error) {
253
+ console.error("โŒ asphodelos:", error);
254
+ }
255
+ })();
256
+ state.queue = queued;
257
+ return queued;
258
+ };
259
+ const runPass = async (server) => {
260
+ const { config } = state;
261
+ if (!config) return;
262
+ console.log("๐ŸŒธ asphodelos");
263
+ const { logs, changed, jobs } = await runWithFileSystem(generate(config));
264
+ for (const line of logs) console.log(line);
265
+ if (!jobs) return;
266
+ const removed = state.jobs === null ? [] : await runWithFileSystem(removeStaleOutputs(state.jobs, jobs));
267
+ for (const removedPath of removed) console.log(`๐Ÿงน removed ${removedPath}`);
268
+ state.jobs = jobs;
269
+ if (changed || removed.length > 0) server.ws.send({ type: "full-reload" });
270
+ };
271
+ /**
272
+ * (Re)reads the config and regenerates from it, whatever the documents look like โ€” the config
273
+ * decides what is generated, so an edit to it is never skipped.
274
+ *
275
+ * A config that does not load leaves the previous one in effect, and the next save retries.
276
+ */
277
+ const applyConfig = async (server) => {
278
+ const loaded = await runWithFileSystem(Effect.result(loadConfig(server, configPath)));
279
+ if (Result.isFailure(loaded)) {
280
+ console.error(`โŒ config: ${loaded.failure}`);
281
+ return;
282
+ }
283
+ state.config = loaded.success;
284
+ const inputPath = path.resolve(process.cwd(), loaded.success.input);
285
+ const inputDirectory = path.dirname(inputPath);
286
+ server.watcher.add([inputPath, ...INPUT_EXTENSIONS.map((extension) => path.join(inputDirectory, `**/*${extension}`))]);
287
+ state.inputDirectory = inputDirectory;
288
+ state.inputHash = await runWithFileSystem(hashInputs(inputDirectory));
289
+ await runPass(server);
290
+ };
291
+ /**
292
+ * Regenerates after a document change, unless the documents read exactly as they did at the
293
+ * last pass. Skipping also requires every output to still exist, so deleting a generated file
294
+ * and touching the input brings it back.
295
+ */
296
+ const applyInputChange = async (server) => {
297
+ const { inputDirectory, jobs } = state;
298
+ if (inputDirectory === null) return;
299
+ const [inputHash, outputsExist] = await runWithFileSystem(Effect.all([hashInputs(inputDirectory), hasAllOutputs(jobs ?? [])]));
300
+ if (inputHash !== null && inputHash === state.inputHash && jobs !== null && outputsExist) {
301
+ console.log("โญ๏ธ asphodelos: input unchanged - skipped regeneration");
302
+ return;
303
+ }
304
+ state.inputHash = inputHash;
305
+ await runPass(server);
306
+ };
307
+ const queueConfigChange = debounce(DEBOUNCE_MS, (server) => {
308
+ enqueue(() => applyConfig(server));
309
+ });
310
+ const queueInputChange = debounce(DEBOUNCE_MS, (server) => {
311
+ enqueue(() => applyInputChange(server));
312
+ });
313
+ return {
314
+ name: "asphodelos",
315
+ apply: "serve",
316
+ configureServer(server) {
317
+ server.watcher.add(configPath);
318
+ server.watcher.on("all", (_eventType, filePath) => {
319
+ const changedPath = path.resolve(filePath);
320
+ if (changedPath === configPath) {
321
+ queueConfigChange(server);
322
+ return;
323
+ }
324
+ if (state.inputDirectory !== null && isWatchedInput(state.inputDirectory, changedPath)) queueInputChange(server);
325
+ });
326
+ enqueue(() => applyConfig(server));
327
+ },
328
+ handleHotUpdate(context) {
329
+ if (path.resolve(context.file) !== configPath) return void 0;
330
+ queueConfigChange(context.server);
331
+ return [];
332
+ }
333
+ };
334
+ }
335
+ //#endregion
336
+ export { asphodelosVite };
package/package.json ADDED
@@ -0,0 +1,71 @@
1
+ {
2
+ "name": "asphodelos",
3
+ "version": "0.0.1",
4
+ "description": "Generate type-safe Elysia code โ€” routes, TypeBox schemas, Eden wrappers, client hooks, tests and a mock server โ€” from OpenAPI or TypeSpec",
5
+ "keywords": [
6
+ "elysia",
7
+ "openapi"
8
+ ],
9
+ "homepage": "https://github.com/nakita628/asphodelos#readme",
10
+ "bugs": {
11
+ "url": "https://github.com/nakita628/asphodelos/issues"
12
+ },
13
+ "license": "MIT",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/nakita628/asphodelos.git",
17
+ "directory": "packages/asphodelos"
18
+ },
19
+ "bin": {
20
+ "asphodelos": "dist/cli.mjs"
21
+ },
22
+ "files": [
23
+ "dist"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "@asphodelos/source": "./src/config/index.ts",
29
+ "types": "./dist/index.d.mts",
30
+ "import": "./dist/index.mjs"
31
+ },
32
+ "./vite-plugin": {
33
+ "@asphodelos/source": "./src/vite-plugin/index.ts",
34
+ "types": "./dist/vite-plugin/index.d.mts",
35
+ "import": "./dist/vite-plugin/index.mjs"
36
+ }
37
+ },
38
+ "scripts": {
39
+ "dev": "bun run src/index.ts",
40
+ "build": "tsdown",
41
+ "lint": "oxlint",
42
+ "lint:fix": "oxlint --fix",
43
+ "test": "bun test",
44
+ "typecheck": "tsc --noEmit",
45
+ "prepublishOnly": "bun run build",
46
+ "prepack": "cp ../../README.md ../../LICENSE .",
47
+ "postpack": "rm -f README.md LICENSE"
48
+ },
49
+ "dependencies": {
50
+ "@apidevtools/swagger-parser": "^12.1.0",
51
+ "@effect/platform-node": "4.0.0-rc.112",
52
+ "@effect/platform-node-shared": "4.0.0-rc.112",
53
+ "@typespec/compiler": "^1.11.0",
54
+ "@typespec/openapi3": "^1.11.0",
55
+ "effect": "4.0.0-rc.112",
56
+ "oxfmt": "^0.67.0",
57
+ "ts-morph": "^28.0.0"
58
+ },
59
+ "devDependencies": {
60
+ "@faker-js/faker": "^10.6.0",
61
+ "@sinclair/typebox": "^0.34.49",
62
+ "@types/bun": "latest",
63
+ "@typespec/http": "^1.11.0",
64
+ "@typespec/rest": "^0.81.0",
65
+ "@typespec/versioning": "^0.81.0",
66
+ "elysia": "^1.4.28",
67
+ "oxlint": "^1.82.0",
68
+ "oxlint-tsgolint": "^7.0.2001",
69
+ "tsdown": "^0.21.10"
70
+ }
71
+ }