@softize/opus 8.6.6
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/CHANGELOG.md +1616 -0
- package/LICENSE +21 -0
- package/README.md +113 -0
- package/bin/cli.mjs +528 -0
- package/bin/lib/check.mjs +307 -0
- package/bin/lib/components.mjs +151 -0
- package/bin/lib/create.mjs +208 -0
- package/bin/lib/db-check-runner.mjs +86 -0
- package/bin/lib/db-migrate-runner.mjs +89 -0
- package/bin/lib/db-scaffold-runner.mjs +84 -0
- package/bin/lib/db.mjs +261 -0
- package/bin/lib/docs-include.mjs +48 -0
- package/bin/lib/gen-dicts.mjs +134 -0
- package/bin/lib/gen-docs.mjs +288 -0
- package/bin/lib/gen-manifest.mjs +102 -0
- package/bin/lib/gen-openapi.mjs +195 -0
- package/bin/lib/gen-runner.mjs +472 -0
- package/bin/lib/gen-stubs.mjs +463 -0
- package/bin/lib/gen.mjs +311 -0
- package/bin/lib/init.mjs +514 -0
- package/bin/lib/introspect.mjs +107 -0
- package/bin/lib/mcp.mjs +85 -0
- package/bin/lib/postinstall.mjs +56 -0
- package/docs/chat-event-protocol.md +85 -0
- package/docs/code-style.md +16 -0
- package/docs/data-layer.md +246 -0
- package/docs/ownership-vs-shadcn-lock.md +102 -0
- package/docs/protocol.md +2053 -0
- package/docs/releasing.md +110 -0
- package/docs/shellnav.md +131 -0
- package/package.json +338 -0
- package/registry/hooks/hooks.json +26 -0
- package/registry/hooks/link-memory-on-start.mjs +46 -0
- package/registry/hooks/opus-check-on-stop.mjs +114 -0
- package/registry/skills/create-action/SKILL.md +49 -0
- package/registry/skills/create-action/scaffold.mjs +122 -0
- package/registry/templates/app/_gitignore +3 -0
- package/registry/templates/app/_npmrc +1 -0
- package/registry/templates/app/_opus/_gitignore +5 -0
- package/registry/templates/app/_prettierrc.json +6 -0
- package/registry/templates/app/index.html +13 -0
- package/registry/templates/app/opus.config.ts +16 -0
- package/registry/templates/app/package.json +43 -0
- package/registry/templates/app/pnpm-workspace.yaml +11 -0
- package/registry/templates/app/public/favicon.svg +4 -0
- package/registry/templates/app/src/App.tsx +37 -0
- package/registry/templates/app/src/domains/tasks/actions/list.test.ts +34 -0
- package/registry/templates/app/src/domains/tasks/actions/list.ts +33 -0
- package/registry/templates/app/src/domains/tasks/index.ts +13 -0
- package/registry/templates/app/src/index.css +18 -0
- package/registry/templates/app/src/main.tsx +25 -0
- package/registry/templates/app/tsconfig.json +20 -0
- package/registry/templates/app/vite.config.ts +46 -0
- package/registry/templates/monorepo/_gitignore +3 -0
- package/registry/templates/monorepo/_npmrc +1 -0
- package/registry/templates/monorepo/package.json +9 -0
- package/registry/templates/monorepo/pnpm-workspace.yaml +14 -0
- package/src/ai/ask.ts +64 -0
- package/src/ai/drivers/anthropic.ts +309 -0
- package/src/ai/index.ts +17 -0
- package/src/audit/drivers/console.ts +117 -0
- package/src/audit/drivers/pg.ts +172 -0
- package/src/audit/index.ts +51 -0
- package/src/auth/drivers/better-auth.ts +103 -0
- package/src/auth/drivers/jwt.ts +188 -0
- package/src/auth/index.ts +9 -0
- package/src/client/drivers/fetch.ts +202 -0
- package/src/client/index.ts +22 -0
- package/src/core/actions.ts +110 -0
- package/src/core/audit.ts +239 -0
- package/src/core/contracts.ts +137 -0
- package/src/core/domain.ts +310 -0
- package/src/core/errors.ts +181 -0
- package/src/core/index.ts +174 -0
- package/src/core/logical-type.ts +31 -0
- package/src/core/reactions.ts +81 -0
- package/src/core/runtime.ts +1167 -0
- package/src/core/schedules.ts +41 -0
- package/src/core/types.ts +1356 -0
- package/src/data/drivers/kysely.ts +389 -0
- package/src/data/index.ts +10 -0
- package/src/data/readonly-pool.ts +160 -0
- package/src/dsl/eval.ts +136 -0
- package/src/dsl/index.ts +29 -0
- package/src/dsl/kysely.ts +230 -0
- package/src/dsl/loads.ts +123 -0
- package/src/dsl/parser.ts +423 -0
- package/src/dsl/types.ts +113 -0
- package/src/events/drivers/mitt.ts +70 -0
- package/src/events/index.ts +9 -0
- package/src/log/drivers/pino.ts +57 -0
- package/src/log/index.ts +9 -0
- package/src/mcp/index.ts +62 -0
- package/src/queue/drivers/bullmq.ts +190 -0
- package/src/queue/index.ts +9 -0
- package/src/scheduler/drivers/node-cron.ts +93 -0
- package/src/scheduler/every.ts +45 -0
- package/src/scheduler/index.ts +9 -0
- package/src/schema/drivers/zod.ts +765 -0
- package/src/schema/entity.ts +439 -0
- package/src/schema/format/locale.ts +144 -0
- package/src/schema/index.ts +65 -0
- package/src/schema/openapi.ts +302 -0
- package/src/schema/scaffold.ts +160 -0
- package/src/server/drivers/fastify.ts +224 -0
- package/src/server/drivers/node.ts +386 -0
- package/src/server/index.ts +142 -0
- package/src/storage/drivers/fs.ts +90 -0
- package/src/storage/drivers/s3.ts +117 -0
- package/src/storage/index.ts +27 -0
- package/src/testing/fake.ts +298 -0
- package/src/testing/index.ts +324 -0
- package/src/ui/components/patterns/action-form-card.tsx +48 -0
- package/src/ui/components/patterns/action-list-dialog.tsx +93 -0
- package/src/ui/components/patterns/app-shell.tsx +227 -0
- package/src/ui/components/patterns/confirm.tsx +226 -0
- package/src/ui/components/patterns/data-state.tsx +75 -0
- package/src/ui/components/patterns/form-dialog.tsx +64 -0
- package/src/ui/components/patterns/form.tsx +584 -0
- package/src/ui/components/patterns/list.tsx +1488 -0
- package/src/ui/components/patterns/page.tsx +46 -0
- package/src/ui/components/patterns/section-shell.tsx +246 -0
- package/src/ui/components/patterns/shell-nav.tsx +150 -0
- package/src/ui/components/patterns/sidebar.tsx +89 -0
- package/src/ui/components/patterns/split.tsx +93 -0
- package/src/ui/components/patterns/trigger.tsx +196 -0
- package/src/ui/components/patterns/view.tsx +84 -0
- package/src/ui/components/primitives/accordion.tsx +64 -0
- package/src/ui/components/primitives/alert-dialog.tsx +190 -0
- package/src/ui/components/primitives/alert.tsx +116 -0
- package/src/ui/components/primitives/aspect-ratio.tsx +9 -0
- package/src/ui/components/primitives/avatar.tsx +107 -0
- package/src/ui/components/primitives/badge.tsx +37 -0
- package/src/ui/components/primitives/breadcrumb.tsx +109 -0
- package/src/ui/components/primitives/button-group.tsx +83 -0
- package/src/ui/components/primitives/button.tsx +102 -0
- package/src/ui/components/primitives/calendar.tsx +218 -0
- package/src/ui/components/primitives/card.tsx +56 -0
- package/src/ui/components/primitives/carousel.tsx +239 -0
- package/src/ui/components/primitives/chat.tsx +407 -0
- package/src/ui/components/primitives/checkbox.tsx +30 -0
- package/src/ui/components/primitives/collapsible.tsx +31 -0
- package/src/ui/components/primitives/command.tsx +182 -0
- package/src/ui/components/primitives/composer.tsx +121 -0
- package/src/ui/components/primitives/copyable.tsx +50 -0
- package/src/ui/components/primitives/dialog.tsx +147 -0
- package/src/ui/components/primitives/drawer.tsx +141 -0
- package/src/ui/components/primitives/empty.tsx +104 -0
- package/src/ui/components/primitives/field.tsx +246 -0
- package/src/ui/components/primitives/icon-picker.tsx +180 -0
- package/src/ui/components/primitives/input-group.tsx +168 -0
- package/src/ui/components/primitives/input-otp.tsx +75 -0
- package/src/ui/components/primitives/input.tsx +72 -0
- package/src/ui/components/primitives/item.tsx +193 -0
- package/src/ui/components/primitives/kbd.tsx +28 -0
- package/src/ui/components/primitives/label.tsx +22 -0
- package/src/ui/components/primitives/markdown.tsx +35 -0
- package/src/ui/components/primitives/menu.tsx +255 -0
- package/src/ui/components/primitives/pagination.tsx +127 -0
- package/src/ui/components/primitives/popover.tsx +87 -0
- package/src/ui/components/primitives/progress.tsx +29 -0
- package/src/ui/components/primitives/radio-group.tsx +43 -0
- package/src/ui/components/primitives/resizable.tsx +51 -0
- package/src/ui/components/primitives/scroll-area.tsx +56 -0
- package/src/ui/components/primitives/select.tsx +479 -0
- package/src/ui/components/primitives/separator.tsx +26 -0
- package/src/ui/components/primitives/skeleton.tsx +13 -0
- package/src/ui/components/primitives/slider.tsx +61 -0
- package/src/ui/components/primitives/sonner.tsx +46 -0
- package/src/ui/components/primitives/spinner.tsx +29 -0
- package/src/ui/components/primitives/switch.tsx +33 -0
- package/src/ui/components/primitives/table.tsx +114 -0
- package/src/ui/components/primitives/tabs.tsx +104 -0
- package/src/ui/components/primitives/textarea.tsx +18 -0
- package/src/ui/components/primitives/toggle-group.tsx +81 -0
- package/src/ui/components/primitives/toggle.tsx +45 -0
- package/src/ui/components/primitives/tooltip.tsx +55 -0
- package/src/ui/components/primitives/truncate.tsx +49 -0
- package/src/ui/docs/DocBrowser.tsx +90 -0
- package/src/ui/docs/changelog.tsx +80 -0
- package/src/ui/docs/content/accordion.md +86 -0
- package/src/ui/docs/content/action-form-card.md +24 -0
- package/src/ui/docs/content/action-form-dialog.md +30 -0
- package/src/ui/docs/content/action-form.md +125 -0
- package/src/ui/docs/content/action-list-dialog.md +68 -0
- package/src/ui/docs/content/action-list.md +194 -0
- package/src/ui/docs/content/action-trigger.md +72 -0
- package/src/ui/docs/content/action-view.md +47 -0
- package/src/ui/docs/content/actions.md +138 -0
- package/src/ui/docs/content/ai.md +112 -0
- package/src/ui/docs/content/alert-dialog.md +73 -0
- package/src/ui/docs/content/alert.md +69 -0
- package/src/ui/docs/content/app-shell.md +155 -0
- package/src/ui/docs/content/aspect-ratio.md +66 -0
- package/src/ui/docs/content/audit.md +84 -0
- package/src/ui/docs/content/auth.md +70 -0
- package/src/ui/docs/content/avatar.md +94 -0
- package/src/ui/docs/content/badge.md +48 -0
- package/src/ui/docs/content/breadcrumb.md +87 -0
- package/src/ui/docs/content/button-group.md +71 -0
- package/src/ui/docs/content/button.md +60 -0
- package/src/ui/docs/content/calendar.md +62 -0
- package/src/ui/docs/content/card.md +49 -0
- package/src/ui/docs/content/carousel.md +85 -0
- package/src/ui/docs/content/chat.md +69 -0
- package/src/ui/docs/content/checkbox.md +75 -0
- package/src/ui/docs/content/cli.md +58 -0
- package/src/ui/docs/content/collapsible.md +64 -0
- package/src/ui/docs/content/command.md +56 -0
- package/src/ui/docs/content/composer.md +50 -0
- package/src/ui/docs/content/confirm.md +120 -0
- package/src/ui/docs/content/copyable.md +30 -0
- package/src/ui/docs/content/customization.md +110 -0
- package/src/ui/docs/content/cycle.md +34 -0
- package/src/ui/docs/content/data-state.md +47 -0
- package/src/ui/docs/content/data.md +99 -0
- package/src/ui/docs/content/dialog.md +60 -0
- package/src/ui/docs/content/drawer.md +55 -0
- package/src/ui/docs/content/empty.md +66 -0
- package/src/ui/docs/content/events.md +61 -0
- package/src/ui/docs/content/field.md +58 -0
- package/src/ui/docs/content/getting-started.md +109 -0
- package/src/ui/docs/content/icon-picker.md +51 -0
- package/src/ui/docs/content/input-group.md +78 -0
- package/src/ui/docs/content/input-otp.md +72 -0
- package/src/ui/docs/content/input.md +78 -0
- package/src/ui/docs/content/item.md +84 -0
- package/src/ui/docs/content/kbd.md +62 -0
- package/src/ui/docs/content/label.md +32 -0
- package/src/ui/docs/content/log.md +55 -0
- package/src/ui/docs/content/markdown.md +41 -0
- package/src/ui/docs/content/mcp.md +44 -0
- package/src/ui/docs/content/menu.md +114 -0
- package/src/ui/docs/content/microcopy.md +83 -0
- package/src/ui/docs/content/page.md +34 -0
- package/src/ui/docs/content/pagination.md +99 -0
- package/src/ui/docs/content/popover.md +49 -0
- package/src/ui/docs/content/progress.md +69 -0
- package/src/ui/docs/content/queue.md +62 -0
- package/src/ui/docs/content/radio-group.md +77 -0
- package/src/ui/docs/content/resizable.md +86 -0
- package/src/ui/docs/content/router.md +56 -0
- package/src/ui/docs/content/runtime.md +77 -0
- package/src/ui/docs/content/scheduler.md +66 -0
- package/src/ui/docs/content/scroll-area.md +89 -0
- package/src/ui/docs/content/section-shell.md +121 -0
- package/src/ui/docs/content/select.md +342 -0
- package/src/ui/docs/content/separator.md +33 -0
- package/src/ui/docs/content/sidebar.md +38 -0
- package/src/ui/docs/content/skeleton.md +34 -0
- package/src/ui/docs/content/slider.md +64 -0
- package/src/ui/docs/content/spinner.md +37 -0
- package/src/ui/docs/content/split.md +33 -0
- package/src/ui/docs/content/storage.md +69 -0
- package/src/ui/docs/content/switch.md +69 -0
- package/src/ui/docs/content/table.md +102 -0
- package/src/ui/docs/content/tabs.md +94 -0
- package/src/ui/docs/content/testing.md +89 -0
- package/src/ui/docs/content/textarea.md +30 -0
- package/src/ui/docs/content/toast.md +67 -0
- package/src/ui/docs/content/toggle-group.md +81 -0
- package/src/ui/docs/content/toggle.md +72 -0
- package/src/ui/docs/content/tokens.md +171 -0
- package/src/ui/docs/content/tooltip.md +50 -0
- package/src/ui/docs/content/truncate.md +37 -0
- package/src/ui/docs/content/ui.md +40 -0
- package/src/ui/docs/content/upgrading.md +48 -0
- package/src/ui/docs/doc-client.tsx +214 -0
- package/src/ui/docs/doc.tsx +301 -0
- package/src/ui/docs/folder.tsx +149 -0
- package/src/ui/docs/index.ts +21 -0
- package/src/ui/docs/markdown.tsx +130 -0
- package/src/ui/docs/md-raw.d.ts +4 -0
- package/src/ui/docs/plugin.ts +104 -0
- package/src/ui/docs/registry.tsx +424 -0
- package/src/ui/docs/standalone.tsx +107 -0
- package/src/ui/drivers/react.tsx +627 -0
- package/src/ui/index.ts +92 -0
- package/src/ui/lib/cn.ts +10 -0
- package/src/ui/lib/zod-pt-br.ts +38 -0
- package/src/ui/meta.ts +412 -0
- package/src/ui/react.tsx +235 -0
- package/src/ui/router.ts +96 -0
- package/src/ui/theme.css +234 -0
- package/src/vite/design.ts +652 -0
- package/src/vite/index.ts +8 -0
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/server/node — driver connect/node-http
|
|
3
|
+
*
|
|
4
|
+
* Implementa `ServerAdapter` sobre `IncomingMessage`/`ServerResponse` puros,
|
|
5
|
+
* sem framework: rotas vivem numa Map interna e o `handler` resultante encaixa
|
|
6
|
+
* em qualquer stack connect-style (middlewares do vite, express, ou um
|
|
7
|
+
* `http.createServer` direto). É o driver que o `opusDesign()` usa pra servir
|
|
8
|
+
* `/api` in-process no preview de design.
|
|
9
|
+
*
|
|
10
|
+
* Uso:
|
|
11
|
+
* import http from 'node:http'
|
|
12
|
+
* import { createRuntime } from '@softize/opus/core'
|
|
13
|
+
* import { nodeServer } from '@softize/opus/server/node'
|
|
14
|
+
*
|
|
15
|
+
* const node = nodeServer()
|
|
16
|
+
* const runtime = createRuntime({ server: node.adapter, auth: ... })
|
|
17
|
+
* runtime.register([...])
|
|
18
|
+
* await runtime.start()
|
|
19
|
+
* http.createServer(node.handler).listen(3000)
|
|
20
|
+
*
|
|
21
|
+
* Semântica do `handler`:
|
|
22
|
+
* - rota montada (actions sob `apiPrefix` + endpoints) → responde;
|
|
23
|
+
* - path sob `apiPrefix` sem rota → 404 em envelope opus;
|
|
24
|
+
* - fora do `apiPrefix` → `next()` quando fornecido, senão 404 em envelope.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
28
|
+
import type {
|
|
29
|
+
ActionDef,
|
|
30
|
+
ActionResult,
|
|
31
|
+
ContextBase,
|
|
32
|
+
EndpointSpec,
|
|
33
|
+
ErrorInput,
|
|
34
|
+
RuntimeRef,
|
|
35
|
+
ServerAdapter,
|
|
36
|
+
} from '../../core/index.ts'
|
|
37
|
+
import { error } from '../../core/index.ts'
|
|
38
|
+
import {
|
|
39
|
+
toOpenAPISpec,
|
|
40
|
+
type OpenAPIInfo,
|
|
41
|
+
type OpenAPIServer,
|
|
42
|
+
} from '../../schema/openapi.ts'
|
|
43
|
+
import {
|
|
44
|
+
httpStatusFor,
|
|
45
|
+
inputSourceFor,
|
|
46
|
+
methodFor,
|
|
47
|
+
pathFor,
|
|
48
|
+
serializeResult,
|
|
49
|
+
shouldMountPublicly,
|
|
50
|
+
successStatusFor,
|
|
51
|
+
} from '../index.ts'
|
|
52
|
+
|
|
53
|
+
// =============================================================================
|
|
54
|
+
// Options
|
|
55
|
+
// =============================================================================
|
|
56
|
+
|
|
57
|
+
export interface NodeServerOptions {
|
|
58
|
+
/** Prefixo do path. Default '/api'. */
|
|
59
|
+
apiPrefix?: string
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Endpoints (health/ready/openapi) montados automaticamente no `init` do
|
|
63
|
+
* runtime. Equivale a chamar `adapter.mountEndpoints(spec)` após o start.
|
|
64
|
+
*/
|
|
65
|
+
endpoints?: EndpointSpec
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Info do OpenAPI document (title, version, description). Quando ausente,
|
|
69
|
+
* usa um default genérico — sobrescreva pra publicar API decente.
|
|
70
|
+
*/
|
|
71
|
+
openapiInfo?: OpenAPIInfo
|
|
72
|
+
|
|
73
|
+
/** Servers do OpenAPI document. */
|
|
74
|
+
openapiServers?: OpenAPIServer[]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface NodeServerHandle {
|
|
78
|
+
/** Vai no `createRuntime({ server })`. */
|
|
79
|
+
adapter: ServerAdapter
|
|
80
|
+
|
|
81
|
+
/** Middleware connect-style: pluga em vite/express/http.createServer. */
|
|
82
|
+
handler: (req: IncomingMessage, res: ServerResponse, next?: () => void) => void
|
|
83
|
+
|
|
84
|
+
/** True se há rota montada pra `method` + `pathname` (actions e endpoints).
|
|
85
|
+
* Deixa quem embala (ex.: opusDesign) distinguir 404 de rota antes de delegar. */
|
|
86
|
+
hasRoute: (method: string, pathname: string) => boolean
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// =============================================================================
|
|
90
|
+
// Factory
|
|
91
|
+
// =============================================================================
|
|
92
|
+
|
|
93
|
+
type RouteFn = (req: IncomingMessage, res: ServerResponse, url: URL) => Promise<void>
|
|
94
|
+
|
|
95
|
+
export function nodeServer(options: NodeServerOptions = {}): NodeServerHandle {
|
|
96
|
+
const {
|
|
97
|
+
apiPrefix = '/api',
|
|
98
|
+
endpoints,
|
|
99
|
+
openapiInfo = { title: 'tbdlib API', version: '0.0.0' },
|
|
100
|
+
openapiServers,
|
|
101
|
+
} = options
|
|
102
|
+
|
|
103
|
+
let runtime: RuntimeRef | undefined
|
|
104
|
+
const routes = new Map<string, RouteFn>()
|
|
105
|
+
const mountedActions: ActionDef[] = []
|
|
106
|
+
|
|
107
|
+
const requireRuntime = (): RuntimeRef => {
|
|
108
|
+
/* v8 ignore next 3 — defensivo; init() é sempre chamado antes de mount/etc */
|
|
109
|
+
if (runtime === undefined) {
|
|
110
|
+
throw new Error('nodeServer: runtime not initialized (init not called)')
|
|
111
|
+
}
|
|
112
|
+
return runtime
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const mountEndpoints = (spec: EndpointSpec): void => {
|
|
116
|
+
mountHealth(routes, spec)
|
|
117
|
+
mountReady(routes, spec, requireRuntime)
|
|
118
|
+
mountOpenAPI(routes, spec, mountedActions, {
|
|
119
|
+
info: openapiInfo,
|
|
120
|
+
...(openapiServers !== undefined ? { servers: openapiServers } : {}),
|
|
121
|
+
apiPrefix,
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const adapter: ServerAdapter = {
|
|
126
|
+
name: 'node',
|
|
127
|
+
kind: 'server',
|
|
128
|
+
|
|
129
|
+
init(rt) {
|
|
130
|
+
runtime = rt
|
|
131
|
+
if (endpoints !== undefined) mountEndpoints(endpoints)
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
mount(action) {
|
|
135
|
+
if (!shouldMountPublicly(action)) return
|
|
136
|
+
mountedActions.push(action)
|
|
137
|
+
|
|
138
|
+
const method = methodFor(action)
|
|
139
|
+
const path = pathFor(action, apiPrefix)
|
|
140
|
+
const source = inputSourceFor(action)
|
|
141
|
+
|
|
142
|
+
routes.set(`${method} ${path}`, async (req, res, url) => {
|
|
143
|
+
const rt = requireRuntime()
|
|
144
|
+
const requestId = crypto.randomUUID()
|
|
145
|
+
const ctx = await resolveCtx(rt, req, requestId)
|
|
146
|
+
|
|
147
|
+
let input: unknown
|
|
148
|
+
if (source === 'body') {
|
|
149
|
+
const raw = await readBody(req)
|
|
150
|
+
// Body vazio vira `undefined` — o schema de input da action decide.
|
|
151
|
+
if (raw.length === 0) {
|
|
152
|
+
input = undefined
|
|
153
|
+
} else {
|
|
154
|
+
try {
|
|
155
|
+
input = JSON.parse(raw)
|
|
156
|
+
} catch {
|
|
157
|
+
sendResult(
|
|
158
|
+
res,
|
|
159
|
+
400,
|
|
160
|
+
driverResult(
|
|
161
|
+
{ code: 'server.invalid_json', category: 'validation', message: 'Request body is not valid JSON' },
|
|
162
|
+
action.name,
|
|
163
|
+
requestId,
|
|
164
|
+
),
|
|
165
|
+
)
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
} else {
|
|
170
|
+
// Strings cruas, mesma semântica do `req.query` do fastify — paridade
|
|
171
|
+
// com prod; os contratos coagem (z.coerce etc).
|
|
172
|
+
input = Object.fromEntries(url.searchParams)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const result = await rt.execute(action.name, input, ctx)
|
|
176
|
+
const status = result.ok
|
|
177
|
+
? successStatusFor(action)
|
|
178
|
+
: httpStatusFor(result.error)
|
|
179
|
+
|
|
180
|
+
sendResult(res, status, result)
|
|
181
|
+
})
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
mountEndpoints,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const handler = (
|
|
188
|
+
req: IncomingMessage,
|
|
189
|
+
res: ServerResponse,
|
|
190
|
+
next?: () => void,
|
|
191
|
+
): void => {
|
|
192
|
+
const url = new URL(req.url ?? '/', 'http://localhost')
|
|
193
|
+
const route = routes.get(`${req.method ?? 'GET'} ${url.pathname}`)
|
|
194
|
+
|
|
195
|
+
if (route !== undefined) {
|
|
196
|
+
route(req, res, url).catch((err) => {
|
|
197
|
+
/* v8 ignore next 7 — defensivo; execute() não lança (devolve envelope) */
|
|
198
|
+
sendResult(
|
|
199
|
+
res,
|
|
200
|
+
500,
|
|
201
|
+
driverResult(
|
|
202
|
+
{ code: 'server.unhandled', category: 'internal', message: String(err) },
|
|
203
|
+
deriveActionName(url.pathname, apiPrefix),
|
|
204
|
+
crypto.randomUUID(),
|
|
205
|
+
),
|
|
206
|
+
)
|
|
207
|
+
})
|
|
208
|
+
return
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (url.pathname === apiPrefix || url.pathname.startsWith(`${apiPrefix}/`)) {
|
|
212
|
+
sendResult(res, 404, routeNotFound(url.pathname, apiPrefix))
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (next !== undefined) {
|
|
217
|
+
next()
|
|
218
|
+
return
|
|
219
|
+
}
|
|
220
|
+
sendResult(res, 404, routeNotFound(url.pathname, apiPrefix))
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
adapter,
|
|
225
|
+
handler,
|
|
226
|
+
hasRoute: (method, pathname) => routes.has(`${method} ${pathname}`),
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// =============================================================================
|
|
231
|
+
// Context resolution
|
|
232
|
+
// =============================================================================
|
|
233
|
+
|
|
234
|
+
async function resolveCtx(
|
|
235
|
+
rt: RuntimeRef,
|
|
236
|
+
req: IncomingMessage,
|
|
237
|
+
requestId: string,
|
|
238
|
+
): Promise<ContextBase> {
|
|
239
|
+
const base = await resolveAuthBase(rt, req)
|
|
240
|
+
return {
|
|
241
|
+
...base,
|
|
242
|
+
requestId,
|
|
243
|
+
provenance: {
|
|
244
|
+
kind: 'http',
|
|
245
|
+
userId: base.user?.id ?? null,
|
|
246
|
+
requestId,
|
|
247
|
+
},
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function resolveAuthBase(
|
|
252
|
+
rt: RuntimeRef,
|
|
253
|
+
req: IncomingMessage,
|
|
254
|
+
): Promise<{ user: ContextBase['user']; tenantId: string | null; can: ContextBase['can'] }> {
|
|
255
|
+
if (rt.auth === undefined) {
|
|
256
|
+
return { user: null, tenantId: null, can: () => false }
|
|
257
|
+
}
|
|
258
|
+
const resolved = await rt.auth.resolveContext(req)
|
|
259
|
+
return {
|
|
260
|
+
user: resolved.user,
|
|
261
|
+
tenantId: resolved.tenantId ?? null,
|
|
262
|
+
can: resolved.can,
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// =============================================================================
|
|
267
|
+
// Request/response plumbing
|
|
268
|
+
// =============================================================================
|
|
269
|
+
|
|
270
|
+
async function readBody(req: IncomingMessage): Promise<string> {
|
|
271
|
+
// setEncoding evita quebrar UTF-8 multi-byte na fronteira de chunks.
|
|
272
|
+
req.setEncoding('utf8')
|
|
273
|
+
let body = ''
|
|
274
|
+
for await (const chunk of req) body += chunk as string
|
|
275
|
+
return body
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function sendResult(res: ServerResponse, status: number, result: ActionResult<unknown>): void {
|
|
279
|
+
res.statusCode = status
|
|
280
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
281
|
+
res.end(JSON.stringify(serializeResult(result)))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Envelope de erro produzido pelo DRIVER (sem passar pelo execute). Carrega
|
|
286
|
+
* `meta` completo — o fetch client só reconhece envelope com `meta` presente.
|
|
287
|
+
*/
|
|
288
|
+
function driverResult(
|
|
289
|
+
input: ErrorInput,
|
|
290
|
+
action: string,
|
|
291
|
+
requestId: string,
|
|
292
|
+
): ActionResult<never> {
|
|
293
|
+
return {
|
|
294
|
+
ok: false,
|
|
295
|
+
error: error(input),
|
|
296
|
+
meta: { actionId: crypto.randomUUID(), action, durationMs: 0, requestId },
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function routeNotFound(pathname: string, apiPrefix: string): ActionResult<never> {
|
|
301
|
+
return driverResult(
|
|
302
|
+
{ code: 'server.route_not_found', category: 'not_found', message: `No route for ${pathname}` },
|
|
303
|
+
deriveActionName(pathname, apiPrefix),
|
|
304
|
+
crypto.randomUUID(),
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Nome de action derivado do path (pro `meta.action` de erros do driver). */
|
|
309
|
+
function deriveActionName(pathname: string, apiPrefix: string): string {
|
|
310
|
+
if (pathname !== apiPrefix && !pathname.startsWith(`${apiPrefix}/`)) return 'unknown'
|
|
311
|
+
const name = pathname
|
|
312
|
+
.slice(apiPrefix.length)
|
|
313
|
+
.replace(/^\/+/, '')
|
|
314
|
+
.split('/')
|
|
315
|
+
.map(decodeURIComponent)
|
|
316
|
+
.join('.')
|
|
317
|
+
return name.length > 0 ? name : 'unknown'
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// =============================================================================
|
|
321
|
+
// Endpoints
|
|
322
|
+
// =============================================================================
|
|
323
|
+
|
|
324
|
+
function mountHealth(routes: Map<string, RouteFn>, spec: EndpointSpec): void {
|
|
325
|
+
const cfg = spec.health
|
|
326
|
+
if (cfg === false) return
|
|
327
|
+
const path = pathFromSpec(cfg, '/health')
|
|
328
|
+
routes.set(`GET ${path}`, async (_req, res) => {
|
|
329
|
+
sendJson(res, 200, { ok: true })
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function mountReady(
|
|
334
|
+
routes: Map<string, RouteFn>,
|
|
335
|
+
spec: EndpointSpec,
|
|
336
|
+
getRuntime: () => RuntimeRef,
|
|
337
|
+
): void {
|
|
338
|
+
const cfg = spec.ready
|
|
339
|
+
if (cfg === false) return
|
|
340
|
+
const path = pathFromSpec(cfg, '/ready')
|
|
341
|
+
routes.set(`GET ${path}`, async (_req, res) => {
|
|
342
|
+
const status = await getRuntime().healthCheck()
|
|
343
|
+
sendJson(res, status.ok ? 200 : 503, status)
|
|
344
|
+
})
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function pathFromSpec(
|
|
348
|
+
cfg: boolean | { path?: string } | undefined,
|
|
349
|
+
fallback: string,
|
|
350
|
+
): string {
|
|
351
|
+
if (typeof cfg === 'object' && cfg.path !== undefined) return cfg.path
|
|
352
|
+
return fallback
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function mountOpenAPI(
|
|
356
|
+
routes: Map<string, RouteFn>,
|
|
357
|
+
spec: EndpointSpec,
|
|
358
|
+
actions: ActionDef[],
|
|
359
|
+
options: {
|
|
360
|
+
info: OpenAPIInfo
|
|
361
|
+
servers?: OpenAPIServer[]
|
|
362
|
+
apiPrefix: string
|
|
363
|
+
},
|
|
364
|
+
): void {
|
|
365
|
+
const cfg = spec.openapi
|
|
366
|
+
if (cfg === false) return
|
|
367
|
+
const path =
|
|
368
|
+
typeof cfg === 'object' && cfg.path !== undefined ? cfg.path : '/openapi.json'
|
|
369
|
+
routes.set(`GET ${path}`, async (_req, res) => {
|
|
370
|
+
sendJson(
|
|
371
|
+
res,
|
|
372
|
+
200,
|
|
373
|
+
toOpenAPISpec(actions, {
|
|
374
|
+
info: options.info,
|
|
375
|
+
...(options.servers !== undefined ? { servers: options.servers } : {}),
|
|
376
|
+
apiPrefix: options.apiPrefix,
|
|
377
|
+
}),
|
|
378
|
+
)
|
|
379
|
+
})
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
383
|
+
res.statusCode = status
|
|
384
|
+
res.setHeader('content-type', 'application/json; charset=utf-8')
|
|
385
|
+
res.end(JSON.stringify(body))
|
|
386
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/server — shared helpers for server drivers.
|
|
3
|
+
*
|
|
4
|
+
* Drivers (Fastify, Hono, etc) consomem este módulo pra:
|
|
5
|
+
* - mapear ActionError.category → HTTP status
|
|
6
|
+
* - derivar route path + method de uma ActionDef
|
|
7
|
+
* - serializar ActionResult em response body
|
|
8
|
+
*
|
|
9
|
+
* Lógica específica de driver (registro de rotas, parsing de request) vive
|
|
10
|
+
* em `src/drivers/<driver>.ts`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import type {
|
|
14
|
+
ActionDef,
|
|
15
|
+
ActionError,
|
|
16
|
+
ActionResult,
|
|
17
|
+
ErrorCategory,
|
|
18
|
+
} from '../core/index.ts'
|
|
19
|
+
|
|
20
|
+
// =============================================================================
|
|
21
|
+
// HTTP status mapping
|
|
22
|
+
// =============================================================================
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Mapping default `ErrorCategory` → HTTP status code.
|
|
26
|
+
* Ver §3 do protocolo.
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_HTTP_STATUS: Record<ErrorCategory, number> = {
|
|
29
|
+
validation: 400,
|
|
30
|
+
authentication: 401,
|
|
31
|
+
authorization: 403,
|
|
32
|
+
not_found: 404,
|
|
33
|
+
conflict: 409,
|
|
34
|
+
rate_limit: 429,
|
|
35
|
+
dependency: 502,
|
|
36
|
+
internal: 500,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Retorna o HTTP status code apropriado pra um `ActionError`.
|
|
41
|
+
*/
|
|
42
|
+
export function httpStatusFor(error: ActionError): number {
|
|
43
|
+
return DEFAULT_HTTP_STATUS[error.category]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Retorna o HTTP status pra success result. Default 200; action pode
|
|
48
|
+
* declarar `successStatus` (ex: 201 pra create).
|
|
49
|
+
*/
|
|
50
|
+
export function successStatusFor(action: ActionDef): number {
|
|
51
|
+
return action.successStatus ?? 200
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// =============================================================================
|
|
55
|
+
// Route convention
|
|
56
|
+
// =============================================================================
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Método HTTP padrão por kind:
|
|
60
|
+
* - simple, form → POST (mutações tipicamente)
|
|
61
|
+
* - search, view → GET (leitura)
|
|
62
|
+
*/
|
|
63
|
+
export type HttpMethod = 'GET' | 'POST'
|
|
64
|
+
|
|
65
|
+
export function methodFor(action: ActionDef): HttpMethod {
|
|
66
|
+
return action.kind === 'simple' || action.kind === 'form' ? 'POST' : 'GET'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Path derivado do nome da action. Converte dots em segments.
|
|
71
|
+
* - 'deal.archive' → '/deal/archive'
|
|
72
|
+
* - 'crm.deal.list' → '/crm/deal/list'
|
|
73
|
+
* - 'auth.sessionRefresh' → '/auth/sessionRefresh'
|
|
74
|
+
*
|
|
75
|
+
* `prefix` adicionado se passado (default '/api').
|
|
76
|
+
*/
|
|
77
|
+
export function pathFor(action: ActionDef, prefix = '/api'): string {
|
|
78
|
+
const segments = action.name.split('.').map(encodeURIComponent)
|
|
79
|
+
return `${prefix}/${segments.join('/')}`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// =============================================================================
|
|
83
|
+
// Body shape
|
|
84
|
+
// =============================================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Decide se o input vem do body (POST) ou query string (GET).
|
|
88
|
+
* Drivers usam pra extrair input do request.
|
|
89
|
+
*/
|
|
90
|
+
export function inputSourceFor(action: ActionDef): 'body' | 'query' {
|
|
91
|
+
return methodFor(action) === 'POST' ? 'body' : 'query'
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// =============================================================================
|
|
95
|
+
// Result serialization
|
|
96
|
+
// =============================================================================
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Envelope JSON-serializável de `ActionResult`. Driver chama `JSON.stringify`
|
|
100
|
+
* direto no retorno desta função.
|
|
101
|
+
*
|
|
102
|
+
* `cause` em error é serializado como string (ver §3 do protocolo).
|
|
103
|
+
*/
|
|
104
|
+
export function serializeResult<T>(result: ActionResult<T>): unknown {
|
|
105
|
+
if (result.ok) {
|
|
106
|
+
return { ok: true, data: result.data, meta: result.meta }
|
|
107
|
+
}
|
|
108
|
+
return {
|
|
109
|
+
ok: false,
|
|
110
|
+
error: serializeError(result.error),
|
|
111
|
+
meta: result.meta,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function serializeError(err: ActionError): Record<string, unknown> {
|
|
116
|
+
const out: Record<string, unknown> = {
|
|
117
|
+
code: err.code,
|
|
118
|
+
category: err.category,
|
|
119
|
+
message: err.message,
|
|
120
|
+
severity: err.severity,
|
|
121
|
+
retriable: err.retriable,
|
|
122
|
+
}
|
|
123
|
+
if (err.i18nKey !== undefined) out.i18nKey = err.i18nKey
|
|
124
|
+
if (err.i18nParams !== undefined) out.i18nParams = err.i18nParams
|
|
125
|
+
if (err.field !== undefined) out.field = err.field
|
|
126
|
+
if (err.issues !== undefined) out.issues = err.issues
|
|
127
|
+
if (err.cause !== undefined) out.cause = String(err.cause)
|
|
128
|
+
if (err.meta !== undefined) out.meta = err.meta
|
|
129
|
+
return out
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// =============================================================================
|
|
133
|
+
// Internal action filter
|
|
134
|
+
// =============================================================================
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Actions com `internal: true` não são expostas via REST público.
|
|
138
|
+
* Driver checa antes de chamar mount().
|
|
139
|
+
*/
|
|
140
|
+
export function shouldMountPublicly(action: ActionDef): boolean {
|
|
141
|
+
return action.internal !== true
|
|
142
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @softize/opus/storage/fs — driver de disco local (EXPERIMENTAL).
|
|
3
|
+
*
|
|
4
|
+
* Guarda cada objeto como arquivo sob `root` (a key vira o caminho relativo) e o
|
|
5
|
+
* `contentType` num sidecar `<arquivo>.meta.json` (o filesystem não tem metadata).
|
|
6
|
+
* Pra dev e single-host; produção multi-host prefere `storage/s3`.
|
|
7
|
+
*
|
|
8
|
+
* `url()` exige `baseUrl` (o app serve o `root` estaticamente por conta própria —
|
|
9
|
+
* o driver não sobe servidor): sem `baseUrl`, url() falha explicando.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { promises as fs } from 'node:fs'
|
|
13
|
+
import path from 'node:path'
|
|
14
|
+
|
|
15
|
+
import type { StorageAdapter, StorageObject, StoragePutOptions } from '../../core/index.ts'
|
|
16
|
+
import { normalizeKey } from '../index.ts'
|
|
17
|
+
|
|
18
|
+
export interface FsStorageOptions {
|
|
19
|
+
/** Diretório raiz dos objetos (criado sob demanda). */
|
|
20
|
+
root: string
|
|
21
|
+
/** Base pública de `url()` (ex.: `https://app.exemplo.com/arquivos`). Sem ela, url() falha. */
|
|
22
|
+
baseUrl?: string
|
|
23
|
+
/** Nome do adapter (default 'fs'). */
|
|
24
|
+
name?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Sidecar de metadata: `<arquivo>.meta.json` ao lado do objeto. */
|
|
28
|
+
function metaPathOf(filePath: string): string {
|
|
29
|
+
return `${filePath}.meta.json`
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function fsStorage(options: FsStorageOptions): StorageAdapter {
|
|
33
|
+
const root = path.resolve(options.root)
|
|
34
|
+
const baseUrl = options.baseUrl?.replace(/\/+$/, '')
|
|
35
|
+
|
|
36
|
+
// normalizeKey barra traversal/absoluto; o resolve + guard abaixo é o cinto extra.
|
|
37
|
+
function filePathOf(key: string): string {
|
|
38
|
+
const full = path.resolve(root, normalizeKey(key))
|
|
39
|
+
if (!full.startsWith(`${root}${path.sep}`)) {
|
|
40
|
+
throw new Error(`Key de storage fora do root: "${key}".`)
|
|
41
|
+
}
|
|
42
|
+
return full
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
name: options.name ?? 'fs',
|
|
47
|
+
kind: 'storage',
|
|
48
|
+
|
|
49
|
+
async put(key: string, data: Uint8Array, opts?: StoragePutOptions): Promise<void> {
|
|
50
|
+
const file = filePathOf(key)
|
|
51
|
+
await fs.mkdir(path.dirname(file), { recursive: true })
|
|
52
|
+
await fs.writeFile(file, data)
|
|
53
|
+
if (opts?.contentType !== undefined) {
|
|
54
|
+
await fs.writeFile(metaPathOf(file), JSON.stringify({ contentType: opts.contentType }))
|
|
55
|
+
}
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
async get(key: string): Promise<StorageObject | null> {
|
|
59
|
+
const file = filePathOf(key)
|
|
60
|
+
let data: Buffer
|
|
61
|
+
try {
|
|
62
|
+
data = await fs.readFile(file)
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
let contentType: string | undefined
|
|
67
|
+
try {
|
|
68
|
+
const meta = JSON.parse(await fs.readFile(metaPathOf(file), 'utf8')) as { contentType?: string }
|
|
69
|
+
contentType = meta.contentType
|
|
70
|
+
} catch {
|
|
71
|
+
/* Sem sidecar → sem contentType. */
|
|
72
|
+
}
|
|
73
|
+
return { data: new Uint8Array(data), ...(contentType !== undefined ? { contentType } : {}) }
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
async delete(key: string): Promise<void> {
|
|
77
|
+
const file = filePathOf(key)
|
|
78
|
+
await fs.rm(file, { force: true })
|
|
79
|
+
await fs.rm(metaPathOf(file), { force: true })
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
async url(key: string): Promise<string> {
|
|
83
|
+
const k = normalizeKey(key)
|
|
84
|
+
if (baseUrl === undefined) {
|
|
85
|
+
throw new Error('fsStorage sem baseUrl — configure-a (e sirva o root) ou use storage/s3 pra URL assinada.')
|
|
86
|
+
}
|
|
87
|
+
return `${baseUrl}/${k}`
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
}
|