@mauroandre/velojs 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.
package/src/vite.ts ADDED
@@ -0,0 +1,937 @@
1
+ import type { Plugin, PluginOption, UserConfig } from "vite";
2
+ import path from "node:path";
3
+ import fs from "node:fs";
4
+ import type { VeloConfig } from "./config.js";
5
+
6
+ // External plugins
7
+ import preact from "@preact/preset-vite";
8
+ import devServer from "@hono/vite-dev-server";
9
+
10
+ // Babel imports
11
+ import { parse } from "@babel/parser";
12
+ import _traverse from "@babel/traverse";
13
+ import _generate from "@babel/generator";
14
+ import * as t from "@babel/types";
15
+
16
+ // Workaround para ESM — handle both CJS-wrapped and direct ESM exports
17
+ const traverse = typeof _traverse === "function"
18
+ ? _traverse
19
+ : (_traverse as unknown as { default: typeof _traverse }).default;
20
+ const generate = typeof _generate === "function"
21
+ ? _generate
22
+ : (_generate as unknown as { default: typeof _generate }).default;
23
+
24
+ // ============================================
25
+ // TRANSFORMAÇÃO 1: Injetar metadata (moduleId + fullPath)
26
+ // ============================================
27
+
28
+ export function injectMetadata(
29
+ code: string,
30
+ moduleId: string,
31
+ fullPath?: string,
32
+ path?: string
33
+ ): string {
34
+ const ast = parse(code, {
35
+ sourceType: "module",
36
+ plugins: ["typescript", "jsx"],
37
+ });
38
+
39
+ let metadataFound = false;
40
+
41
+ traverse(ast, {
42
+ // Procura: export const metadata = { ... }
43
+ ExportNamedDeclaration(nodePath) {
44
+ const declaration = nodePath.node.declaration;
45
+
46
+ if (
47
+ t.isVariableDeclaration(declaration) &&
48
+ declaration.declarations.length === 1
49
+ ) {
50
+ const declarator = declaration.declarations[0];
51
+
52
+ if (
53
+ declarator &&
54
+ t.isIdentifier(declarator.id, { name: "metadata" }) &&
55
+ t.isObjectExpression(declarator.init)
56
+ ) {
57
+ metadataFound = true;
58
+ const properties = declarator.init.properties;
59
+
60
+ // Remove moduleId, fullPath e path existentes se houver
61
+ const filteredProps = properties.filter((prop) => {
62
+ if (
63
+ t.isObjectProperty(prop) &&
64
+ t.isIdentifier(prop.key)
65
+ ) {
66
+ return (
67
+ prop.key.name !== "moduleId" &&
68
+ prop.key.name !== "fullPath" &&
69
+ prop.key.name !== "path"
70
+ );
71
+ }
72
+ return true;
73
+ });
74
+
75
+ // Adiciona moduleId
76
+ const newProps: t.ObjectProperty[] = [
77
+ t.objectProperty(
78
+ t.identifier("moduleId"),
79
+ t.stringLiteral(moduleId)
80
+ ),
81
+ ];
82
+
83
+ // Adiciona fullPath se disponível
84
+ if (fullPath !== undefined) {
85
+ newProps.push(
86
+ t.objectProperty(
87
+ t.identifier("fullPath"),
88
+ t.stringLiteral(fullPath)
89
+ )
90
+ );
91
+ }
92
+
93
+ // Adiciona path se disponível
94
+ if (path !== undefined) {
95
+ newProps.push(
96
+ t.objectProperty(
97
+ t.identifier("path"),
98
+ t.stringLiteral(path)
99
+ )
100
+ );
101
+ }
102
+
103
+ declarator.init.properties = [...newProps, ...filteredProps];
104
+ }
105
+ }
106
+ },
107
+ });
108
+
109
+ // Se não encontrou metadata, adiciona no início
110
+ if (!metadataFound) {
111
+ const props: t.ObjectProperty[] = [
112
+ t.objectProperty(
113
+ t.identifier("moduleId"),
114
+ t.stringLiteral(moduleId)
115
+ ),
116
+ ];
117
+
118
+ if (fullPath !== undefined) {
119
+ props.push(
120
+ t.objectProperty(
121
+ t.identifier("fullPath"),
122
+ t.stringLiteral(fullPath)
123
+ )
124
+ );
125
+ }
126
+
127
+ if (path !== undefined) {
128
+ props.push(
129
+ t.objectProperty(
130
+ t.identifier("path"),
131
+ t.stringLiteral(path)
132
+ )
133
+ );
134
+ }
135
+
136
+ const metadataExport = t.exportNamedDeclaration(
137
+ t.variableDeclaration("const", [
138
+ t.variableDeclarator(
139
+ t.identifier("metadata"),
140
+ t.objectExpression(props)
141
+ ),
142
+ ])
143
+ );
144
+
145
+ ast.program.body.unshift(metadataExport);
146
+ }
147
+
148
+ const output = generate(ast, { retainLines: true });
149
+ return output.code;
150
+ }
151
+
152
+ // ============================================
153
+ // TRANSFORMAÇÃO 2: Injetar moduleId no Loader e useLoader
154
+ // ============================================
155
+
156
+ export function transformLoaderFunctions(code: string, moduleId: string): string {
157
+ const ast = parse(code, {
158
+ sourceType: "module",
159
+ plugins: ["typescript", "jsx"],
160
+ });
161
+
162
+ traverse(ast, {
163
+ CallExpression(nodePath) {
164
+ const callee = nodePath.node.callee;
165
+
166
+ // Verifica se é chamada de Loader ou useLoader (com ou sem type params)
167
+ const isLoader =
168
+ t.isIdentifier(callee, { name: "Loader" }) ||
169
+ (t.isTSInstantiationExpression(callee) &&
170
+ t.isIdentifier(callee.expression, { name: "Loader" }));
171
+
172
+ const isUseLoader =
173
+ t.isIdentifier(callee, { name: "useLoader" }) ||
174
+ (t.isTSInstantiationExpression(callee) &&
175
+ t.isIdentifier(callee.expression, { name: "useLoader" }));
176
+
177
+ if (!isLoader && !isUseLoader) return;
178
+
179
+ const args = nodePath.node.arguments;
180
+ const moduleIdArg = t.stringLiteral(moduleId);
181
+
182
+ // Se já tem moduleId (string com "/" como primeiro arg), não faz nada
183
+ const firstArg = args[0];
184
+ if (t.isStringLiteral(firstArg) && firstArg.value.includes("/")) {
185
+ return;
186
+ }
187
+
188
+ // Prepende moduleId como primeiro argumento, mantendo os existentes
189
+ // useLoader() → useLoader("moduleId")
190
+ // useLoader([deps]) → useLoader("moduleId", [deps])
191
+ nodePath.node.arguments = [moduleIdArg, ...args];
192
+ },
193
+ });
194
+
195
+ const output = generate(ast, { retainLines: true });
196
+ return output.code;
197
+ }
198
+
199
+ // ============================================
200
+ // TRANSFORMAÇÃO 3: Actions → Fetch stubs (client only)
201
+ // ============================================
202
+
203
+ export function transformActionsForClient(code: string, moduleId: string): string {
204
+ const ast = parse(code, {
205
+ sourceType: "module",
206
+ plugins: ["typescript", "jsx"],
207
+ });
208
+
209
+ traverse(ast, {
210
+ ExportNamedDeclaration(nodePath) {
211
+ const declaration = nodePath.node.declaration;
212
+
213
+ // Procura: export const action_xxx = async (...) => { ... }
214
+ if (!t.isVariableDeclaration(declaration)) return;
215
+
216
+ const declarator = declaration.declarations[0];
217
+ if (!declarator || !t.isIdentifier(declarator.id)) return;
218
+
219
+ const name = declarator.id.name;
220
+ if (!name.startsWith("action_")) return;
221
+
222
+ const actionName = name.replace("action_", "");
223
+
224
+ // Verifica se é arrow function async
225
+ const init = declarator.init;
226
+ if (!t.isArrowFunctionExpression(init) || !init.async) return;
227
+
228
+ const params = init.params;
229
+
230
+ // Cria o corpo do fetch stub
231
+ const fetchCall = createFetchStub(moduleId, actionName, params);
232
+
233
+ // Substitui o corpo da função
234
+ init.body = t.blockStatement([t.returnStatement(fetchCall)]);
235
+
236
+ // Ajusta os parâmetros para o client
237
+ adjustParamsForClient(init, params);
238
+ },
239
+ });
240
+
241
+ const output = generate(ast, { retainLines: true });
242
+ return output.code;
243
+ }
244
+
245
+ /**
246
+ * Cria a expressão fetch para o stub
247
+ */
248
+ function createFetchStub(
249
+ moduleId: string,
250
+ actionName: string,
251
+ params: t.ArrowFunctionExpression["params"]
252
+ ): t.Expression {
253
+ const url = `/_action/${moduleId}/${actionName}`;
254
+
255
+ // Se não tem parâmetros, fetch simples sem body
256
+ if (params.length === 0) {
257
+ return t.callExpression(
258
+ t.memberExpression(
259
+ t.callExpression(t.identifier("fetch"), [
260
+ t.stringLiteral(url),
261
+ t.objectExpression([
262
+ t.objectProperty(
263
+ t.identifier("method"),
264
+ t.stringLiteral("POST")
265
+ ),
266
+ ]),
267
+ ]),
268
+ t.identifier("then")
269
+ ),
270
+ [
271
+ t.arrowFunctionExpression(
272
+ [t.identifier("r")],
273
+ t.callExpression(
274
+ t.memberExpression(
275
+ t.identifier("r"),
276
+ t.identifier("json")
277
+ ),
278
+ []
279
+ )
280
+ ),
281
+ ]
282
+ );
283
+ }
284
+
285
+ // Com parâmetros - precisa enviar body
286
+ return t.callExpression(
287
+ t.memberExpression(
288
+ t.callExpression(t.identifier("fetch"), [
289
+ t.stringLiteral(url),
290
+ t.objectExpression([
291
+ t.objectProperty(
292
+ t.identifier("method"),
293
+ t.stringLiteral("POST")
294
+ ),
295
+ t.objectProperty(
296
+ t.identifier("headers"),
297
+ t.objectExpression([
298
+ t.objectProperty(
299
+ t.stringLiteral("Content-Type"),
300
+ t.stringLiteral("application/json")
301
+ ),
302
+ ])
303
+ ),
304
+ t.objectProperty(
305
+ t.identifier("body"),
306
+ t.callExpression(
307
+ t.memberExpression(
308
+ t.identifier("JSON"),
309
+ t.identifier("stringify")
310
+ ),
311
+ [t.identifier("body")]
312
+ )
313
+ ),
314
+ ]),
315
+ ]),
316
+ t.identifier("then")
317
+ ),
318
+ [
319
+ t.arrowFunctionExpression(
320
+ [t.identifier("r")],
321
+ t.callExpression(
322
+ t.memberExpression(t.identifier("r"), t.identifier("json")),
323
+ []
324
+ )
325
+ ),
326
+ ]
327
+ );
328
+ }
329
+
330
+ /**
331
+ * Ajusta os parâmetros da action para o client
332
+ * Ex: ({ body, c }: ActionArgs<LoginBody>) → ({ body }: { body: LoginBody })
333
+ */
334
+ function adjustParamsForClient(
335
+ fn: t.ArrowFunctionExpression,
336
+ params: t.ArrowFunctionExpression["params"]
337
+ ): void {
338
+ if (params.length === 0) return;
339
+
340
+ const firstParam = params[0];
341
+
342
+ // Se é ObjectPattern (desestruturação), mantém só { body }
343
+ if (t.isObjectPattern(firstParam)) {
344
+ // Extrai o tipo do body se tiver ActionArgs<T>
345
+ let bodyType: t.TSType | null = null;
346
+
347
+ if (
348
+ t.isTSTypeAnnotation(firstParam.typeAnnotation) &&
349
+ t.isTSTypeReference(firstParam.typeAnnotation.typeAnnotation)
350
+ ) {
351
+ const typeRef = firstParam.typeAnnotation.typeAnnotation;
352
+
353
+ // Verifica se é ActionArgs<T>
354
+ if (
355
+ t.isIdentifier(typeRef.typeName, { name: "ActionArgs" }) &&
356
+ typeRef.typeParameters?.params[0]
357
+ ) {
358
+ bodyType = typeRef.typeParameters.params[0];
359
+ }
360
+ }
361
+
362
+ // Cria novo parâmetro: { body }: { body: T }
363
+ const bodyProp = t.objectProperty(
364
+ t.identifier("body"),
365
+ t.identifier("body"),
366
+ false,
367
+ true // shorthand
368
+ );
369
+
370
+ const newParam = t.objectPattern([bodyProp]);
371
+
372
+ if (bodyType) {
373
+ newParam.typeAnnotation = t.tsTypeAnnotation(
374
+ t.tsTypeLiteral([
375
+ t.tsPropertySignature(
376
+ t.identifier("body"),
377
+ t.tsTypeAnnotation(bodyType)
378
+ ),
379
+ ])
380
+ );
381
+ }
382
+
383
+ fn.params = [newParam];
384
+ }
385
+ }
386
+
387
+ // ============================================
388
+ // TRANSFORMAÇÃO 4: Remover loaders (client only)
389
+ // ============================================
390
+
391
+ export function removeLoaders(code: string): string {
392
+ const ast = parse(code, {
393
+ sourceType: "module",
394
+ plugins: ["typescript", "jsx"],
395
+ });
396
+
397
+ traverse(ast, {
398
+ ExportNamedDeclaration(nodePath) {
399
+ const declaration = nodePath.node.declaration;
400
+
401
+ // Procura: export const loader = ...
402
+ if (!t.isVariableDeclaration(declaration)) return;
403
+
404
+ const declarator = declaration.declarations[0];
405
+ if (!declarator || !t.isIdentifier(declarator.id)) return;
406
+
407
+ if (declarator.id.name === "loader") {
408
+ // Remove o export inteiro
409
+ nodePath.remove();
410
+ }
411
+ },
412
+ });
413
+
414
+ const output = generate(ast, { retainLines: true });
415
+ return output.code;
416
+ }
417
+
418
+ // ============================================
419
+ // TRANSFORMAÇÃO 5: Remover middlewares e imports (client only)
420
+ // ============================================
421
+
422
+ export function removeMiddlewares(code: string): string {
423
+ const ast = parse(code, {
424
+ sourceType: "module",
425
+ plugins: ["typescript", "jsx"],
426
+ });
427
+
428
+ // Fase 1: Coleta identificadores usados em middlewares: [...]
429
+ const middlewareIdentifiers = new Set<string>();
430
+
431
+ traverse(ast, {
432
+ ObjectProperty(nodePath) {
433
+ // Procura: middlewares: [...]
434
+ if (
435
+ t.isIdentifier(nodePath.node.key, { name: "middlewares" }) &&
436
+ t.isArrayExpression(nodePath.node.value)
437
+ ) {
438
+ // Coleta todos os identificadores no array
439
+ for (const element of nodePath.node.value.elements) {
440
+ if (t.isIdentifier(element)) {
441
+ middlewareIdentifiers.add(element.name);
442
+ }
443
+ }
444
+
445
+ // Remove a propriedade middlewares
446
+ nodePath.remove();
447
+ }
448
+ },
449
+ });
450
+
451
+ // Se não encontrou middlewares, retorna código original
452
+ if (middlewareIdentifiers.size === 0) {
453
+ return code;
454
+ }
455
+
456
+ // Fase 2: Remove imports dos identificadores de middleware
457
+ traverse(ast, {
458
+ ImportDeclaration(nodePath) {
459
+ const specifiers = nodePath.node.specifiers;
460
+
461
+ // Filtra os specifiers, removendo os que são middlewares
462
+ const remainingSpecifiers = specifiers.filter((specifier) => {
463
+ if (t.isImportSpecifier(specifier)) {
464
+ const localName = specifier.local.name;
465
+ return !middlewareIdentifiers.has(localName);
466
+ }
467
+ // Mantém default imports e namespace imports
468
+ return true;
469
+ });
470
+
471
+ if (remainingSpecifiers.length === 0) {
472
+ // Remove o import inteiro se não sobrou nenhum specifier
473
+ nodePath.remove();
474
+ } else if (remainingSpecifiers.length !== specifiers.length) {
475
+ // Atualiza os specifiers se alguns foram removidos
476
+ nodePath.node.specifiers = remainingSpecifiers;
477
+ }
478
+ },
479
+ });
480
+
481
+ const output = generate(ast, { retainLines: true });
482
+ return output.code;
483
+ }
484
+
485
+ // ============================================
486
+ // TRANSFORMAÇÃO 6: Extrair e injetar fullPaths
487
+ // ============================================
488
+
489
+ export interface PathInfo {
490
+ fullPath: string;
491
+ path: string;
492
+ }
493
+
494
+ /**
495
+ * Parseia routes.tsx e retorna Map de moduleId → { fullPath, path }
496
+ * moduleId aqui é derivado do import source (ex: "./auth/Login.js" → "auth/Login")
497
+ */
498
+ export function buildFullPathMap(code: string): Map<string, PathInfo> {
499
+ const ast = parse(code, {
500
+ sourceType: "module",
501
+ plugins: ["typescript", "jsx"],
502
+ });
503
+
504
+ // Fase 1: Coletar imports - localName → source
505
+ const imports = new Map<string, string>();
506
+
507
+ traverse(ast, {
508
+ ImportDeclaration(nodePath) {
509
+ const source = nodePath.node.source.value;
510
+
511
+ for (const specifier of nodePath.node.specifiers) {
512
+ if (t.isImportNamespaceSpecifier(specifier)) {
513
+ // import * as Name from "./path"
514
+ imports.set(specifier.local.name, source);
515
+ }
516
+ }
517
+ },
518
+ });
519
+
520
+ // Fase 2: Percorrer rotas e coletar moduleName → { fullPath, path }
521
+ const moduleNameToPaths = new Map<string, PathInfo>();
522
+
523
+ traverse(ast, {
524
+ ExportDefaultDeclaration(nodePath) {
525
+ const declaration = nodePath.node.declaration;
526
+
527
+ let arrayNode: t.ArrayExpression | null = null;
528
+
529
+ if (t.isArrayExpression(declaration)) {
530
+ arrayNode = declaration;
531
+ } else if (
532
+ t.isTSSatisfiesExpression(declaration) &&
533
+ t.isArrayExpression(declaration.expression)
534
+ ) {
535
+ arrayNode = declaration.expression;
536
+ }
537
+
538
+ if (arrayNode) {
539
+ collectFullPaths(arrayNode.elements, "", moduleNameToPaths);
540
+ }
541
+ },
542
+ });
543
+
544
+ // Fase 3: Converter moduleName → moduleId baseado no import source
545
+ const result = new Map<string, PathInfo>();
546
+
547
+ for (const [moduleName, pathInfo] of moduleNameToPaths) {
548
+ const source = imports.get(moduleName);
549
+ if (source) {
550
+ // "./auth/Login.js" → "auth/Login"
551
+ const moduleId = source
552
+ .replace(/^\.\//, "")
553
+ .replace(/\.(tsx?|jsx?|js)$/, "");
554
+ result.set(moduleId, pathInfo);
555
+ }
556
+ }
557
+
558
+ return result;
559
+ }
560
+
561
+ /**
562
+ * Percorre recursivamente a árvore de rotas e coleta moduleName → { fullPath, path }
563
+ */
564
+ export function collectFullPaths(
565
+ elements: (t.Expression | t.SpreadElement | null)[],
566
+ parentPath: string,
567
+ result: Map<string, PathInfo>
568
+ ): void {
569
+ for (const element of elements) {
570
+ if (!t.isObjectExpression(element)) continue;
571
+
572
+ let nodePath = "";
573
+ let currentPath = parentPath;
574
+ let moduleName: string | null = null;
575
+ let childrenNode: t.ArrayExpression | null = null;
576
+
577
+ for (const prop of element.properties) {
578
+ if (!t.isObjectProperty(prop)) continue;
579
+
580
+ const key = prop.key;
581
+ const keyName = t.isIdentifier(key) ? key.name : null;
582
+
583
+ if (keyName === "path" && t.isStringLiteral(prop.value)) {
584
+ nodePath = prop.value.value;
585
+ // Adiciona / entre parentPath e nodePath se necessário
586
+ if (nodePath && !nodePath.startsWith("/")) {
587
+ currentPath = parentPath + "/" + nodePath;
588
+ } else {
589
+ currentPath = parentPath + nodePath;
590
+ }
591
+ }
592
+
593
+ if (keyName === "module" && t.isIdentifier(prop.value)) {
594
+ moduleName = prop.value.name;
595
+ }
596
+
597
+ if (keyName === "children" && t.isArrayExpression(prop.value)) {
598
+ childrenNode = prop.value;
599
+ }
600
+ }
601
+
602
+ // Adiciona o módulo com seu fullPath e path
603
+ if (moduleName) {
604
+ // Se é folha (sem children) e path é "/", fullPath = parentPath (sem trailing slash)
605
+ // Isso permite que wouter use "/" para index routes, mas servidor registra sem trailing slash
606
+ const isLeafWithSlash = !childrenNode && nodePath === "/";
607
+ const effectiveFullPath = isLeafWithSlash ? (parentPath || "/") : currentPath;
608
+
609
+ result.set(moduleName, {
610
+ fullPath: effectiveFullPath,
611
+ path: nodePath,
612
+ });
613
+ }
614
+
615
+ // Recursivamente processa os filhos
616
+ if (childrenNode) {
617
+ collectFullPaths(childrenNode.elements, currentPath, result);
618
+ }
619
+ }
620
+ }
621
+
622
+ // ============================================
623
+ // VIRTUAL MODULE IDs
624
+ // ============================================
625
+
626
+ const VIRTUAL_SERVER_ENTRY = "virtual:velo/server-entry";
627
+ const VIRTUAL_CLIENT_ENTRY = "virtual:velo/client-entry";
628
+ const RESOLVED_VIRTUAL_SERVER = "\0" + VIRTUAL_SERVER_ENTRY;
629
+ const RESOLVED_VIRTUAL_CLIENT = "\0" + VIRTUAL_CLIENT_ENTRY;
630
+
631
+ // ============================================
632
+ // VITE PLUGIN - TRANSFORM (internal)
633
+ // ============================================
634
+
635
+ function veloTransformPlugin(veloConfig: VeloConfig, appDirectory: string): Plugin {
636
+ // Initialize with cwd as fallback, will be updated in configResolved
637
+ let appDir: string = path.resolve(process.cwd(), appDirectory);
638
+ const routesFile = veloConfig.routesFile ?? "routes.tsx";
639
+
640
+ // Map de moduleId → { fullPath, path } (populado no buildStart)
641
+ const pathInfoMap = new Map<string, PathInfo>();
642
+
643
+ return {
644
+ name: "velo:transform",
645
+ enforce: "pre",
646
+
647
+ configResolved(resolvedConfig) {
648
+ appDir = path.resolve(resolvedConfig.root, appDirectory);
649
+ },
650
+
651
+ buildStart() {
652
+ // Lê routes.tsx e popula o Map de paths
653
+ const routesFilePath = path.join(appDir, routesFile);
654
+ if (fs.existsSync(routesFilePath)) {
655
+ const routesCode = fs.readFileSync(routesFilePath, "utf-8");
656
+ const paths = buildFullPathMap(routesCode);
657
+ pathInfoMap.clear();
658
+ for (const [moduleId, pathInfo] of paths) {
659
+ pathInfoMap.set(moduleId, pathInfo);
660
+ }
661
+ }
662
+ },
663
+
664
+ handleHotUpdate({ file, server }) {
665
+ // Reconstrói o Map quando routes.tsx muda
666
+ const routesFilePath = path.join(appDir, routesFile);
667
+ if (file === routesFilePath) {
668
+ const routesCode = fs.readFileSync(routesFilePath, "utf-8");
669
+ const paths = buildFullPathMap(routesCode);
670
+ pathInfoMap.clear();
671
+ for (const [moduleId, pathInfo] of paths) {
672
+ pathInfoMap.set(moduleId, pathInfo);
673
+ }
674
+ // Força reload completo para aplicar novas rotas
675
+ server.ws.send({ type: "full-reload" });
676
+ return [];
677
+ }
678
+ },
679
+
680
+ resolveId(id) {
681
+ // Handle server entry (with or without project root prefix)
682
+ if (id === VIRTUAL_SERVER_ENTRY || id.endsWith(VIRTUAL_SERVER_ENTRY)) {
683
+ return RESOLVED_VIRTUAL_SERVER;
684
+ }
685
+ // Handle client entry (with or without project root prefix)
686
+ if (
687
+ id === VIRTUAL_CLIENT_ENTRY ||
688
+ id.endsWith(VIRTUAL_CLIENT_ENTRY) ||
689
+ id === "/__velo_client.js"
690
+ ) {
691
+ return RESOLVED_VIRTUAL_CLIENT;
692
+ }
693
+ return null;
694
+ },
695
+
696
+ load(id) {
697
+ const routesFile = veloConfig.routesFile ?? "routes.tsx";
698
+ const serverInit = veloConfig.serverInit ?? "server.tsx";
699
+ const clientInit = veloConfig.clientInit ?? "client.tsx";
700
+
701
+ // Paths relativos ao appDir
702
+ const routesPath = path.join(appDir, routesFile).replace(/\.tsx?$/, ".js");
703
+ const serverInitPath = path.join(appDir, serverInit).replace(/\.tsx?$/, ".js");
704
+ const clientInitPath = path.join(appDir, clientInit).replace(/\.tsx?$/, ".js");
705
+
706
+ if (id === RESOLVED_VIRTUAL_SERVER) {
707
+ return `
708
+ import "${serverInitPath}";
709
+ import routes from "${routesPath}";
710
+ import { startServer } from "@mauroandre/velojs/server";
711
+
712
+ export default await startServer({ routes });
713
+ `;
714
+ }
715
+
716
+ if (id === RESOLVED_VIRTUAL_CLIENT) {
717
+ return `
718
+ import "${clientInitPath}";
719
+ import routes from "${routesPath}";
720
+ import { startClient } from "@mauroandre/velojs/client";
721
+
722
+ startClient({ routes });
723
+ `;
724
+ }
725
+
726
+ return null;
727
+ },
728
+
729
+ transform(code, id, transformOptions) {
730
+ const isSSR = transformOptions?.ssr === true;
731
+
732
+ // Ignora virtual modules
733
+ if (id.startsWith("\0")) return null;
734
+
735
+ // Ignora arquivos dentro de .velojs/
736
+ if (id.includes("/.velojs/")) return null;
737
+
738
+ // Ignora arquivos que não são tsx/ts
739
+ if (!id.endsWith(".tsx") && !id.endsWith(".ts")) return null;
740
+
741
+ // Ignora arquivos fora do diretório da aplicação
742
+ if (!id.startsWith(appDir)) return null;
743
+
744
+ let transformedCode = code;
745
+ let hasTransformations = false;
746
+
747
+ // Verifica padrões no código
748
+ const hasMiddlewares = /middlewares:\s*\[/.test(code);
749
+ const hasComponent = /export\s+(const|function)\s+Component/.test(
750
+ code
751
+ );
752
+ const hasLoader = /export\s+(const|function)\s+loader/.test(code);
753
+ const hasAction = /export\s+const\s+action_\w+/.test(code);
754
+ const hasLoaderCall = /\bLoader\s*</.test(code) || /\bLoader\s*\(/.test(code);
755
+ const hasUseLoaderCall = /\buseLoader\s*</.test(code) || /\buseLoader\s*\(/.test(code);
756
+
757
+ // 5. Remover middlewares e imports (client only)
758
+ if (!isSSR && hasMiddlewares) {
759
+ transformedCode = removeMiddlewares(transformedCode);
760
+ hasTransformations = true;
761
+ }
762
+
763
+ // Se tem Component, loader, action, ou chamadas de Loader/useLoader, aplica transformações
764
+ if (hasComponent || hasLoader || hasAction || hasLoaderCall || hasUseLoaderCall) {
765
+ const moduleId = path
766
+ .relative(appDir, id)
767
+ .replace(/\.(tsx?|jsx?)$/, "")
768
+ .replace(/\\/g, "/");
769
+
770
+ // Busca pathInfo no Map
771
+ const pathInfo = pathInfoMap.get(moduleId);
772
+
773
+ // 1. Injeta metadata.moduleId, metadata.fullPath e metadata.path
774
+ transformedCode = injectMetadata(transformedCode, moduleId, pathInfo?.fullPath, pathInfo?.path);
775
+
776
+ // 2. Transformar Loader e useLoader
777
+ transformedCode = transformLoaderFunctions(transformedCode, moduleId);
778
+
779
+ // 3. Transformar actions em fetch stubs (client only)
780
+ if (!isSSR) {
781
+ transformedCode = transformActionsForClient(
782
+ transformedCode,
783
+ moduleId
784
+ );
785
+ }
786
+
787
+ // 4. Remover loaders (client only)
788
+ if (!isSSR) {
789
+ transformedCode = removeLoaders(transformedCode);
790
+ }
791
+
792
+ hasTransformations = true;
793
+ }
794
+
795
+ if (!hasTransformations) return null;
796
+
797
+ return {
798
+ code: transformedCode,
799
+ map: null,
800
+ };
801
+ },
802
+ };
803
+ }
804
+
805
+ // ============================================
806
+ // VITE PLUGIN - CONFIG (internal)
807
+ // ============================================
808
+
809
+ function veloConfigPlugin(): Plugin {
810
+ return {
811
+ name: "velo:config",
812
+
813
+ config(userConfig, { mode }) {
814
+ const isServer = mode === "server";
815
+ const isDev = mode === "development";
816
+
817
+ const config: UserConfig = {
818
+ define: {
819
+ "process.env.NODE_ENV": JSON.stringify(isDev ? "development" : "production"),
820
+ "process.env.STATIC_BASE_URL": JSON.stringify(process.env.STATIC_BASE_URL || ""),
821
+ },
822
+ resolve: {
823
+ alias: {
824
+ react: "preact/compat",
825
+ "react-dom": "preact/compat",
826
+ },
827
+ },
828
+ };
829
+
830
+ if (isServer) {
831
+ config.build = {
832
+ ssr: VIRTUAL_SERVER_ENTRY,
833
+ outDir: "dist",
834
+ emptyOutDir: false,
835
+ copyPublicDir: false,
836
+ rollupOptions: {
837
+ output: {
838
+ entryFileNames: "server.js",
839
+ },
840
+ },
841
+ };
842
+ } else if (!isDev) {
843
+ // Client production build
844
+ config.build = {
845
+ manifest: true,
846
+ outDir: "dist/client",
847
+ rollupOptions: {
848
+ input: VIRTUAL_CLIENT_ENTRY,
849
+ output: {
850
+ entryFileNames: "client.js",
851
+ assetFileNames: (assetInfo) => {
852
+ if (assetInfo.names?.[0]?.endsWith(".css")) {
853
+ return "client.css";
854
+ }
855
+ return "[name][extname]";
856
+ },
857
+ },
858
+ },
859
+ };
860
+ }
861
+
862
+ return config;
863
+ },
864
+ };
865
+ }
866
+
867
+ // ============================================
868
+ // VITE PLUGIN - STATIC URL REWRITE (internal)
869
+ // ============================================
870
+
871
+ /**
872
+ * Rewrites root-relative url() paths in CSS at build time.
873
+ * url(/img/foo.png) → url(STATIC_BASE_URL/img/foo.png)
874
+ *
875
+ * This allows users to write normal CSS paths and have them
876
+ * automatically point to a bucket/CDN when STATIC_BASE_URL is set.
877
+ * Only runs during build — in dev, paths stay as-is.
878
+ */
879
+ function veloStaticUrlPlugin(): Plugin {
880
+ return {
881
+ name: "velo:static-url",
882
+ apply: "build",
883
+ enforce: "post",
884
+
885
+ generateBundle(_, bundle) {
886
+ const staticBase = process.env.STATIC_BASE_URL || "";
887
+ if (!staticBase) return;
888
+
889
+ for (const chunk of Object.values(bundle)) {
890
+ if (
891
+ chunk.type === "asset" &&
892
+ typeof chunk.source === "string" &&
893
+ chunk.fileName.endsWith(".css")
894
+ ) {
895
+ // Rewrite url(/...) but not url(//...) (protocol-relative)
896
+ chunk.source = chunk.source.replace(
897
+ /url\(\s*(['"]?)\/(?!\/)/g,
898
+ `url($1${staticBase}/`
899
+ );
900
+ }
901
+ }
902
+ },
903
+ };
904
+ }
905
+
906
+ // ============================================
907
+ // MAIN EXPORT
908
+ // ============================================
909
+
910
+ export function veloPlugin(config?: VeloConfig): PluginOption[] {
911
+ const veloConfig: VeloConfig = config ?? {};
912
+ const appDirectory = veloConfig.appDirectory ?? "./app";
913
+
914
+ return [
915
+ veloConfigPlugin(),
916
+ veloTransformPlugin(veloConfig, appDirectory),
917
+ veloStaticUrlPlugin(),
918
+ preact(),
919
+ devServer({
920
+ entry: VIRTUAL_SERVER_ENTRY,
921
+ }),
922
+ veloWsBridgePlugin(),
923
+ ];
924
+ }
925
+
926
+ /**
927
+ * Exposes Vite's HTTP server via globalThis so the app can attach
928
+ * WebSocket upgrade handlers in dev mode.
929
+ */
930
+ function veloWsBridgePlugin(): Plugin {
931
+ return {
932
+ name: "velo:ws-bridge",
933
+ configureServer(viteServer) {
934
+ (globalThis as any).__veloDevServer = viteServer.httpServer;
935
+ },
936
+ };
937
+ }