@lpdjs/firestore-repo-service 2.6.11 → 2.6.13
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/dist/servers/hono/cli.cjs +258 -66
- package/dist/servers/hono/cli.cjs.map +1 -1
- package/dist/servers/hono/cli.js +258 -66
- package/dist/servers/hono/cli.js.map +1 -1
- package/dist/servers/hono/index.cjs +1 -1
- package/dist/servers/hono/index.cjs.map +1 -1
- package/dist/servers/hono/index.js +1 -1
- package/dist/servers/hono/index.js.map +1 -1
- package/package.json +1 -1
package/dist/servers/hono/cli.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {existsSync,mkdirSync,writeFileSync,readFileSync,readdirSync,statSync}from'fs';import {resolve,dirname,relative,join,sep}from'path';import {stdin,stdout}from'process';import {createInterface}from'readline/promises';var
|
|
2
|
+
import {existsSync,mkdirSync,writeFileSync,readFileSync,readdirSync,statSync}from'fs';import {resolve,dirname,relative,join,sep}from'path';import {stdin,stdout}from'process';import {createInterface}from'readline/promises';var _={skipSegments:["useCases","useCase","use-cases","use-case"],casing:"preserve"};function J(e,r=_){let s=new Set(r.skipSegments.map(o=>o.toLowerCase()));return "/"+e.split("/").filter(Boolean).filter(o=>!s.has(o.toLowerCase())).map(o=>r.casing==="kebab"?ne(o):o).join("/")}function ne(e){return e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").replace(/[\s_]+/g,"-").toLowerCase()}function Z(e,r,s){let t=W(e),o=W(r),i=0;for(;i<t.length&&i<o.length&&t[i]===o[i];)i++;let a=t.length-i,n=o.slice(i),f=(n[n.length-1]??"").replace(/\.[mc]?[tj]sx?$/i,""),g=s===""?f:`${f}${s}`;return n[n.length-1]=g,(a===0?"./":"../".repeat(a))+n.join("/")}function W(e){return e.replace(/\\/g,"/").replace(/\/+$/,"").split("/").filter((s,t)=>!(t===0&&s===""))}var pe="/**\n * AUTO-GENERATED by `@lpdjs/firestore-repo-service` Hono codegen.\n * Do not edit by hand \u2014 re-run `hono:gen` after adding / removing route files.\n */\n";function K(e,r){let s=dirname(r.outFile);mkdirSync(s,{recursive:true});let t=r.banner??pe,o=(r.now??new Date).toISOString(),i=r.importExtension,a=[],n=[],c=[];e.forEach((g,p)=>{let b=Z(s,g.absPath,i),h=J(g.relDir,r.derive);a.push(`import mod${p} from ${JSON.stringify(b)};`),n.push(` { __derivedPath: ${JSON.stringify(h)}, mod: mod${p} },`),c.push({source:g.relPath,url:h});});let f=`${t}// Generated at ${o} \u2014 ${e.length} route file${e.length===1?"":"s"}.
|
|
3
3
|
|
|
4
4
|
import type { AnyRouteDef, RouteModuleDefault } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
5
5
|
|
|
6
|
-
`+
|
|
7
|
-
`)+(
|
|
6
|
+
`+a.join(`
|
|
7
|
+
`)+(a.length?`
|
|
8
8
|
|
|
9
9
|
`:`
|
|
10
10
|
`)+`const __defs: { __derivedPath: string; mod: RouteModuleDefault }[] = [
|
|
@@ -16,8 +16,8 @@ export const routes: AnyRouteDef[] = __defs.flatMap(({ __derivedPath, mod }) =>
|
|
|
16
16
|
const list = Array.isArray(mod) ? mod : [mod];
|
|
17
17
|
return list.map((route) => ({ ...route, path: route.path ?? __derivedPath }));
|
|
18
18
|
});
|
|
19
|
-
`;return writeFileSync(
|
|
20
|
-
`,"utf8"),s}function
|
|
19
|
+
`;return writeFileSync(r.outFile,f,"utf8"),{outFile:r.outFile,routeCount:e.length,derivedPaths:c}}var I={routesFile:"routes.ts",excludeSegments:["node_modules","__generated__","tests","__tests__",".turbo","dist","build",".next"]};function V(e,r=I){let s=[];return Y(e,e,r,s),s.sort((t,o)=>t.relPath.localeCompare(o.relPath)),s}function Y(e,r,s,t){let o;try{o=readdirSync(r);}catch{return}for(let i of o){if(s.excludeSegments.includes(i))continue;let a=join(r,i),n;try{n=statSync(a);}catch{continue}if(n.isDirectory())Y(e,a,s,t);else if(n.isFile()&&i===s.routesFile){let c=relative(e,a).split(sep).join("/"),f=c.replace(/\/?[^/]+$/,"");t.push({absPath:a,relPath:c,relDir:f});}}}var se=".frsrc.json";function z(e=process.cwd()){let r=resolve(e,se);if(!existsSync(r))return {};try{return JSON.parse(readFileSync(r,"utf8"))}catch{return {}}}function ve(e,r=process.cwd()){let s=resolve(r,se),o={...z(r),...e};return writeFileSync(s,`${JSON.stringify(o,null,2)}
|
|
20
|
+
`,"utf8"),s}function ye(e){let[r,...s]=e,t={};for(let o=0;o<s.length;o++){let i=s[o];if(!i.startsWith("--"))continue;let a=i.slice(2),n=s[o+1];n&&!n.startsWith("--")?(t[a]=n,o++):t[a]=true;}return {command:r??"help",flags:t}}function X(){console.log(`frs \u2014 Hono file-based codegen
|
|
21
21
|
|
|
22
22
|
Usage:
|
|
23
23
|
frs init [flags]
|
|
@@ -83,21 +83,20 @@ Examples:
|
|
|
83
83
|
frs new createPost --domain posts --method post
|
|
84
84
|
frs new listPosts --domain posts --method get --api v1
|
|
85
85
|
frs add service postRepo
|
|
86
|
-
`);}function
|
|
87
|
-
* ${
|
|
86
|
+
`);}function ee(e){if(typeof e=="string")return e.split(",").map(r=>r.trim()).filter(Boolean)}function m(e){return typeof e=="string"?e:void 0}function te(e){if(e||!stdin.isTTY)return {ask:async(s,t)=>t??"",askChoice:async(s,t,o)=>o??"",askBool:async(s,t)=>t,close:()=>{}};let r=createInterface({input:stdin,output:stdout});return {async ask(s,t){let o=t?` (${t})`:"";return (await r.question(`? ${s}${o} \u203A `)).trim()||t||""},async askChoice(s,t,o){let i=` [${t.join("/")}${o?`, default: ${o}`:""}]`;for(;;){let a=(await r.question(`? ${s}${i} \u203A `)).trim().toLowerCase();if(!a&&o)return o;if(t.includes(a))return a;console.log(` invalid choice \u2014 pick one of: ${t.join(", ")}`);}},async askBool(s,t){let o=` (${t?"Y/n":"y/N"})`,i=(await r.question(`? ${s}${o} \u203A `)).trim().toLowerCase();return i?i==="y"||i==="yes"||i==="true":t},close:()=>r.close()}}async function xe(e){let r=z(),s=m(e.root)??r.root;s||(console.error("[frs] --root is required (or run `frs init` to write it to .frsrc.json)"),process.exit(2));let t=resolve(process.cwd(),s);existsSync(t)||(console.error(`[frs] root not found: ${t}`),process.exit(2));let o=m(e.out)??r.out??"__generated__/routes.ts",i=ee(e.skip)??_.skipSegments,a=m(e.casing)==="kebab"?"kebab":_.casing,n={skipSegments:i,casing:a},c=m(e.ext)??".js",f=ee(e.exclude)??I.excludeSegments,g=m(e["routes-file"])??I.routesFile,b=V(t,{routesFile:g,excludeSegments:f});b.length===0&&console.warn(`[frs] no "${g}" files found under ${t} \u2014 generated an empty manifest.`);let h=K(b,{outFile:resolve(t,o),derive:n,importExtension:c});if(!e.silent){console.log(`[frs] wrote ${h.outFile} (${h.routeCount} route${h.routeCount===1?"":"s"})`);for(let{source:P,url:y}of h.derivedPaths)console.log(` ${y.padEnd(48)} \u2190 ${P}`);}}async function we(e,r){let s=r.yes===true,t=te(s),o=z();try{let i=e&&!e.startsWith("--")?e:void 0;i||(i=(await t.ask("Route name (e.g. createPost)")).trim(),i||(console.error("[frs] route name is required"),process.exit(2)));let a=m(r.domain);a||(a=(await t.ask("Domain name (e.g. posts)")).trim(),a||(console.error("[frs] --domain is required"),process.exit(2)));let n=m(r.root)??o.root??"src/domains",c=m(r.method)?.toLowerCase();c||(c=await t.askChoice("HTTP method",["get","post","put","patch","delete"],"post")),["get","post","put","patch","delete"].includes(c)||(console.error(`[frs] invalid --method: ${c}`),process.exit(2));let f=m(r.api);if(!f){let v=o.apis?.[0]??"v1";f=(await t.ask("API tag",v)).trim()||v;}let g=m(r["usecase-folder"])??o.useCaseFolder??"useCases",p=r["with-usecase"]===void 0?s?!0:await t.askBool("Scaffold useCase.ts?",!0):r["with-usecase"]!==!1,b=r["with-test"]===void 0?s||!p?p:await t.askBool("Scaffold useCase.test.ts (Vitest)?",!0):r["with-test"]!==!1,h=r.force===!0,P=resolve(process.cwd(),n),y=resolve(P,a,g,i),F=resolve(y,"routes.ts"),x=`${a}.${i}.useCase`,A=resolve(y,`${x}.ts`),C=resolve(y,`${x}.test.ts`);mkdirSync(y,{recursive:!0});let k=v=>v.charAt(0).toUpperCase()+v.slice(1),$=`${k(a)}${k(i)}UseCase`,D="@lpdjs/firestore-repo-service/servers/hono",w=Ce(P,y),E=Se(P,y),L=c==="get"?"// GET \u2192 lu depuis les query params":`// ${c.toUpperCase()} \u2192 lu depuis le body JSON`,U=`/**
|
|
87
|
+
* ${$} \u2014 pure business logic, no HTTP awareness.
|
|
88
88
|
*
|
|
89
89
|
* Owns its Zod \`input\` / \`output\` schemas (declared as \`static\` members, the
|
|
90
90
|
* single source of truth shared with \`routes.ts\`) and runs the logic in
|
|
91
|
-
* \`execute\`.
|
|
92
|
-
*
|
|
91
|
+
* \`execute\`. Extends \`AppUseCase\`, so \`this.services\`, \`this.logger\` and
|
|
92
|
+
* \`this.error\` are all available.
|
|
93
93
|
*/
|
|
94
94
|
|
|
95
95
|
import { z } from "zod";
|
|
96
|
-
import {
|
|
97
|
-
import type { Services } from "${h}";
|
|
96
|
+
import { AppUseCase } from "${E}";
|
|
98
97
|
|
|
99
98
|
const input = z.object({
|
|
100
|
-
${
|
|
99
|
+
${L}
|
|
101
100
|
example: z.string(),
|
|
102
101
|
});
|
|
103
102
|
|
|
@@ -105,41 +104,45 @@ const output = z.object({
|
|
|
105
104
|
id: z.string(),
|
|
106
105
|
});
|
|
107
106
|
|
|
108
|
-
export class ${
|
|
107
|
+
export class ${$} extends AppUseCase<typeof input, typeof output> {
|
|
109
108
|
static readonly input = input;
|
|
110
109
|
static readonly output = output;
|
|
111
110
|
|
|
112
111
|
async execute(
|
|
113
112
|
payload: z.infer<typeof input>,
|
|
114
113
|
): Promise<z.infer<typeof output>> {
|
|
114
|
+
this.logger.info("${$} called", { example: payload.example });
|
|
115
|
+
// Guard example \u2014 mapped to HTTP by the AppErrorHandler:
|
|
116
|
+
// if (!payload.example) throw this.error.badRequest("example is required");
|
|
117
|
+
|
|
115
118
|
// TODO: implement using \`this.services\`
|
|
116
119
|
return { id: payload.example };
|
|
117
120
|
}
|
|
118
121
|
}
|
|
119
|
-
`,
|
|
120
|
-
source: "query",`:"",
|
|
121
|
-
`:"",
|
|
122
|
-
import { useCaseRoute } from "${
|
|
123
|
-
${
|
|
122
|
+
`,H=c==="get"?`
|
|
123
|
+
source: "query",`:"",N=p?`import { ${$} } from "./${x}.js";
|
|
124
|
+
`:"",O=m(r["apis-import"])??Pe(P,y),u=p?`import { defineRoutes } from "${D}";
|
|
125
|
+
import { useCaseRoute } from "${O}";
|
|
126
|
+
${N}
|
|
124
127
|
export default defineRoutes([
|
|
125
|
-
useCaseRoute(${
|
|
126
|
-
api: "${
|
|
127
|
-
method: "${
|
|
128
|
+
useCaseRoute(${$}, {
|
|
129
|
+
api: "${f}",
|
|
130
|
+
method: "${c}",${H}
|
|
128
131
|
summary: "TODO: ${i}",
|
|
129
|
-
tags: ["${
|
|
132
|
+
tags: ["${a}"],
|
|
130
133
|
}),
|
|
131
134
|
]);
|
|
132
135
|
`:`import { z } from "zod";
|
|
133
136
|
import { defineRoutes } from "${D}";
|
|
134
|
-
import { defineRoute } from "${
|
|
137
|
+
import { defineRoute } from "${O}";
|
|
135
138
|
|
|
136
139
|
export default defineRoutes([
|
|
137
140
|
defineRoute({
|
|
138
|
-
api: "${
|
|
139
|
-
method: "${
|
|
141
|
+
api: "${f}",
|
|
142
|
+
method: "${c}",
|
|
140
143
|
|
|
141
144
|
input: z.object({
|
|
142
|
-
${
|
|
145
|
+
${L}
|
|
143
146
|
example: z.string(),
|
|
144
147
|
}),
|
|
145
148
|
|
|
@@ -148,7 +151,7 @@ export default defineRoutes([
|
|
|
148
151
|
}),
|
|
149
152
|
|
|
150
153
|
summary: "TODO: ${i}",
|
|
151
|
-
tags: ["${
|
|
154
|
+
tags: ["${a}"],
|
|
152
155
|
|
|
153
156
|
handler: async ({ input }) => {
|
|
154
157
|
// TODO: business logic
|
|
@@ -156,38 +159,40 @@ export default defineRoutes([
|
|
|
156
159
|
},
|
|
157
160
|
}),
|
|
158
161
|
]);
|
|
159
|
-
`,
|
|
160
|
-
import type { Services } from "${
|
|
161
|
-
import { ${
|
|
162
|
+
`,j=[],G=[],M=(v,oe)=>{if(existsSync(v)&&!h){G.push(v);return}writeFileSync(v,oe,"utf8"),j.push(v);};if(M(F,u),p&&M(A,U),p&&b){let v=`import { describe, it, expect } from "vitest";
|
|
163
|
+
import type { Services } from "${w}";
|
|
164
|
+
import { ${$} } from "./${x}.js";
|
|
162
165
|
|
|
163
|
-
describe("${
|
|
166
|
+
describe("${$}", () => {
|
|
164
167
|
it("returns a response shaped like the output schema", async () => {
|
|
165
168
|
// TODO: replace with real mocks for the services the useCase consumes.
|
|
166
169
|
const services = {} as unknown as Services;
|
|
167
170
|
|
|
168
|
-
const useCase = new ${
|
|
171
|
+
const useCase = new ${$}(services);
|
|
169
172
|
const result = await useCase.execute({ example: "hello" });
|
|
170
173
|
expect(result).toMatchObject({ id: expect.any(String) });
|
|
171
174
|
});
|
|
172
175
|
|
|
173
176
|
// TODO: add error-path tests, repository mocks, etc.
|
|
174
177
|
});
|
|
175
|
-
`;
|
|
176
|
-
[frs] reminder: run "frs gen --root ${n}" to refresh the manifest.`);}finally{
|
|
177
|
-
basePath: "${
|
|
178
|
+
`;M(C,v);}for(let v of j)console.log(`[frs] wrote ${v}`);for(let v of G)console.log(`[frs] skipped ${v} (use --force to overwrite)`);console.log(`
|
|
179
|
+
[frs] reminder: run "frs gen --root ${n}" to refresh the manifest.`);}finally{t.close();}}async function $e(e){let r=e.yes===true,s=te(r);try{let t=e.force===!0,o=m(e.root);o||(o=(await s.ask("Domain root","src/domains")).trim()||"src/domains");let i=m(e["apis-file"]);i||(i=(await s.ask("apis.ts location","src/apis.ts")).trim()||"src/apis.ts");let a=m(e["services-file"]);if(!a){let u=i.replace(/apis\.ts$/,"services.ts")||"src/services.ts";a=(await s.ask("services.ts location",u)).trim()||u;}let n=m(e.apis);n||(n=(await s.ask("API tags (comma-separated)","v1")).trim()||"v1");let c=n.split(",").map(u=>u.trim()).filter(Boolean);c.length===0&&(console.error("[frs] at least one API tag is required"),process.exit(2));let f=m(e["base-path"]),g=resolve(process.cwd(),o),p=resolve(process.cwd(),i),b=resolve(process.cwd(),a),h=resolve(dirname(p),"app-error.ts"),P=resolve(dirname(p),"base-usecase.ts"),y=resolve(g,"__generated__"),F=resolve(y,"routes.ts"),x=[],A=[],C=(u,j)=>{if(mkdirSync(dirname(u),{recursive:!0}),existsSync(u)&&!t){A.push(u);return}writeFileSync(u,j,"utf8"),x.push(u);},k=c.map(u=>{let j=f??`/${u}`;return ` ${u}: {
|
|
180
|
+
basePath: "${j}",
|
|
178
181
|
openapi: {
|
|
179
|
-
info: { title: "${
|
|
182
|
+
info: { title: "${u.toUpperCase()} API", version: "1.0.0", description: "" },
|
|
180
183
|
},
|
|
181
|
-
//
|
|
182
|
-
//
|
|
183
|
-
errorHandler: new
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
logger
|
|
184
|
+
// Maps your AppError \u2192 HTTP (extend it in app-error.ts). \`gcpLogs\` adds a
|
|
185
|
+
// dev-only deep link to the matching GCP log in the error response.
|
|
186
|
+
errorHandler: new AppErrorHandler({
|
|
187
|
+
gcpLogs: { enabled: process.env["NODE_ENV"] !== "production" },
|
|
188
|
+
}),
|
|
189
|
+
// Shared structured logger (same instance exposed as \`this.logger\`).
|
|
190
|
+
logger: appLogger,
|
|
187
191
|
verbose: process.env["NODE_ENV"] !== "production",
|
|
188
192
|
},`}).join(`
|
|
189
|
-
`)
|
|
190
|
-
import {
|
|
193
|
+
`),$=`import { createApiRegistry } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
194
|
+
import { AppErrorHandler, appLogger } from "${R(dirname(p),h)}";
|
|
195
|
+
import { services } from "${R(dirname(p),b)}";
|
|
191
196
|
|
|
192
197
|
/**
|
|
193
198
|
* Single source of truth for every API exposed by this project.
|
|
@@ -196,12 +201,12 @@ import { services } from "${I(dirname(p),P)}";
|
|
|
196
201
|
* Per-API resources injected into every handler / interceptor / error-handler
|
|
197
202
|
* context (override them per API above):
|
|
198
203
|
* - \`services\` \u2014 shared DI container (\`services.ctx.c\` = current request);
|
|
199
|
-
* - \`errorHandler\` \u2014 maps thrown
|
|
200
|
-
* - \`logger\` \u2014 structured logging (
|
|
204
|
+
* - \`errorHandler\` \u2014 maps thrown \`AppError\`s \u2192 HTTP (see app-error.ts);
|
|
205
|
+
* - \`logger\` \u2014 structured logging (also \`this.logger\` in useCases).
|
|
201
206
|
*/
|
|
202
207
|
export const apis = createApiRegistry(
|
|
203
208
|
{
|
|
204
|
-
${
|
|
209
|
+
${k}
|
|
205
210
|
},
|
|
206
211
|
{ services },
|
|
207
212
|
);
|
|
@@ -209,7 +214,7 @@ ${R}
|
|
|
209
214
|
/** Typed helpers used inside every route file. */
|
|
210
215
|
export const defineRoute = apis.defineRoute;
|
|
211
216
|
export const useCaseRoute = apis.useCaseRoute;
|
|
212
|
-
|
|
217
|
+
`;C(p,$),C(b,`import { createServices } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
213
218
|
|
|
214
219
|
/**
|
|
215
220
|
* Global DI container \u2014 declare every singleton (repositories, SDK
|
|
@@ -242,43 +247,230 @@ export const services = createServices({
|
|
|
242
247
|
|
|
243
248
|
/** Convenience type \u2014 \`function fn(svc: Services) { ... }\`. */
|
|
244
249
|
export type Services = typeof services;
|
|
245
|
-
`)
|
|
250
|
+
`),C(h,`import {
|
|
251
|
+
BaseErrorHandler,
|
|
252
|
+
BaseLogger,
|
|
253
|
+
type ErrorHandlerContext,
|
|
254
|
+
type LogSeverity,
|
|
255
|
+
} from "@lpdjs/firestore-repo-service/servers/hono";
|
|
256
|
+
|
|
257
|
+
/** Supported locales \u2014 the single source of truth (runtime + type). */
|
|
258
|
+
export const LOCALES = ["en", "fr"] as const;
|
|
259
|
+
|
|
260
|
+
/** A supported locale, derived from {@link LOCALES}. */
|
|
261
|
+
export type Locale = (typeof LOCALES)[number];
|
|
262
|
+
|
|
263
|
+
/** Localized message \u2014 one string per supported locale. */
|
|
264
|
+
export type LocalizedMessage = Record<Locale, string>;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Domain error \u2014 pure business semantics, zero HTTP awareness. Thrown anywhere
|
|
268
|
+
* in useCases / handlers; the \`AppErrorHandler\` below maps it to an HTTP
|
|
269
|
+
* response. Carries a localized message; add your own factory methods as your
|
|
270
|
+
* domain grows.
|
|
271
|
+
*/
|
|
272
|
+
export class AppError extends Error {
|
|
273
|
+
readonly statusCode: number;
|
|
274
|
+
readonly userFacing: boolean;
|
|
275
|
+
readonly errorId: string;
|
|
276
|
+
readonly localizedMessage: LocalizedMessage;
|
|
277
|
+
|
|
278
|
+
private constructor(
|
|
279
|
+
localizedMessage: LocalizedMessage,
|
|
280
|
+
statusCode: number,
|
|
281
|
+
userFacing = false,
|
|
282
|
+
) {
|
|
283
|
+
super(localizedMessage.en);
|
|
284
|
+
this.name = "AppError";
|
|
285
|
+
this.statusCode = statusCode;
|
|
286
|
+
this.userFacing = userFacing;
|
|
287
|
+
this.localizedMessage = localizedMessage;
|
|
288
|
+
this.errorId = Math.random().toString(36).slice(2, 12);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** Business message shown directly to the user \u2014 HTTP 412. */
|
|
292
|
+
static userMessage(message: LocalizedMessage): AppError {
|
|
293
|
+
return new AppError(message, 412, true);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** Resource not found \u2014 HTTP 404. */
|
|
297
|
+
static notFound(resource?: string): AppError {
|
|
298
|
+
return new AppError(
|
|
299
|
+
{
|
|
300
|
+
en: \`\${resource ?? "Resource"} not found\`,
|
|
301
|
+
fr: \`\${resource ?? "Ressource"} introuvable\`,
|
|
302
|
+
},
|
|
303
|
+
404,
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Malformed request / invalid data \u2014 HTTP 400. */
|
|
308
|
+
static badRequest(detail?: string): AppError {
|
|
309
|
+
return new AppError(
|
|
310
|
+
{
|
|
311
|
+
en: \`Bad request: \${detail ?? "invalid parameters"}\`,
|
|
312
|
+
fr: \`Requ\xEAte invalide : \${detail ?? "param\xE8tres incorrects"}\`,
|
|
313
|
+
},
|
|
314
|
+
400,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** Generic fallback message for non-user-facing errors. */
|
|
319
|
+
static default(locale: Locale): string {
|
|
320
|
+
return locale === "fr" ? "Une erreur est survenue" : "An error occurred";
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Pick the response locale from the \`Accept-Language\` header.
|
|
326
|
+
*
|
|
327
|
+
* Parses the comma-separated, q-weighted list (e.g.
|
|
328
|
+
* \`fr-FR,fr;q=0.9,en;q=0.8\`), keeps the supported locales, and returns the one
|
|
329
|
+
* with the highest quality. Falls back to \`"en"\`.
|
|
330
|
+
*/
|
|
331
|
+
function pickLocale(c: {
|
|
332
|
+
req: { header(name: string): string | undefined };
|
|
333
|
+
}): Locale {
|
|
334
|
+
const header = c.req.header("accept-language");
|
|
335
|
+
if (!header) return "en";
|
|
336
|
+
|
|
337
|
+
const ranked = header
|
|
338
|
+
.split(",")
|
|
339
|
+
.map((part) => {
|
|
340
|
+
const [tag = "", ...params] = part.trim().split(";");
|
|
341
|
+
const qParam = params.find((p) => p.trim().startsWith("q="));
|
|
342
|
+
const q = qParam ? Number.parseFloat(qParam.split("=")[1] ?? "") : 1;
|
|
343
|
+
return {
|
|
344
|
+
lang: tag.trim().toLowerCase().split("-")[0] ?? "",
|
|
345
|
+
quality: Number.isFinite(q) ? q : 1,
|
|
346
|
+
};
|
|
347
|
+
})
|
|
348
|
+
.filter((x): x is { lang: Locale; quality: number } =>
|
|
349
|
+
(LOCALES as readonly string[]).includes(x.lang),
|
|
350
|
+
)
|
|
351
|
+
.sort((a, b) => b.quality - a.quality);
|
|
352
|
+
|
|
353
|
+
return ranked[0]?.lang ?? "en";
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Project logger \u2014 extends \`BaseLogger\` and overrides the single \`write\` hook.
|
|
358
|
+
* Swap \`console\` for \`firebase-functions/v2\` \`logger\` in real code.
|
|
359
|
+
*/
|
|
360
|
+
export class AppLogger extends BaseLogger {
|
|
361
|
+
protected override write(
|
|
362
|
+
severity: LogSeverity,
|
|
363
|
+
payload: Record<string, unknown>,
|
|
364
|
+
): void {
|
|
365
|
+
// eslint-disable-next-line no-console
|
|
366
|
+
console.log(JSON.stringify({ severity, ...payload }));
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Shared logger instance (per-API \`logger\` + \`this.logger\` in useCases). */
|
|
371
|
+
export const appLogger = new AppLogger();
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Project error strategy \u2014 extends \`BaseErrorHandler\`: \`mapError\` localizes our
|
|
375
|
+
* \`AppError\` (user-facing aware, with an optional GCP logs deep link),
|
|
376
|
+
* \`logError\` routes through the logger, and unmatched errors fall back to the
|
|
377
|
+
* built-in mapping via \`super\`. Wired per API in apis.ts.
|
|
378
|
+
*/
|
|
379
|
+
export class AppErrorHandler extends BaseErrorHandler {
|
|
380
|
+
protected override mapError({
|
|
381
|
+
error,
|
|
382
|
+
c,
|
|
383
|
+
}: ErrorHandlerContext): Response | null {
|
|
384
|
+
if (!(error instanceof AppError)) return null; // \u2192 built-in mapping
|
|
385
|
+
|
|
386
|
+
const locale = pickLocale(c);
|
|
387
|
+
const logsUrl = this.gcpLogsUrl(error.errorId); // undefined when disabled
|
|
388
|
+
return c.json(
|
|
389
|
+
{
|
|
390
|
+
// expose the localized message only when it is meant for the user
|
|
391
|
+
error: error.userFacing
|
|
392
|
+
? error.localizedMessage[locale]
|
|
393
|
+
: AppError.default(locale),
|
|
394
|
+
errorId: error.errorId,
|
|
395
|
+
...(logsUrl ? { logsUrl } : {}),
|
|
396
|
+
},
|
|
397
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
398
|
+
error.statusCode as any,
|
|
399
|
+
);
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
protected override logError({ error, logger }: ErrorHandlerContext): void {
|
|
403
|
+
const log = logger ?? appLogger;
|
|
404
|
+
if (error instanceof AppError && error.statusCode < 500) {
|
|
405
|
+
log.warn(error.message);
|
|
406
|
+
} else {
|
|
407
|
+
log.error(error);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
`);let E=`import { UseCase } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
412
|
+
import type { z } from "zod";
|
|
413
|
+
import { AppError, appLogger } from "${R(dirname(P),h)}";
|
|
414
|
+
import type { Services } from "${R(dirname(P),b)}";
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Project base class for every useCase \u2014 extends the package's {@link UseCase}
|
|
418
|
+
* (which injects \`this.services\` via the constructor) and adds two shared
|
|
419
|
+
* ergonomics: \`this.logger\` (structured logger) and \`this.error\` (the
|
|
420
|
+
* {@link AppError} factory, mapped to HTTP by the \`AppErrorHandler\`).
|
|
421
|
+
* Subclasses still declare \`static input\` / \`static output\`.
|
|
422
|
+
*/
|
|
423
|
+
export abstract class AppUseCase<
|
|
424
|
+
TInput extends z.ZodTypeAny = z.ZodTypeAny,
|
|
425
|
+
TOutput extends z.ZodTypeAny = z.ZodTypeAny,
|
|
426
|
+
> extends UseCase<TInput, TOutput, Services> {
|
|
427
|
+
/** Shared structured logger instance (same one injected per-API). */
|
|
428
|
+
protected readonly logger = appLogger;
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Domain error factory \u2014 \`throw this.error.notFound(...)\` /
|
|
432
|
+
* \`this.error.badRequest(...)\` / \`this.error.userMessage(...)\`. The thrown
|
|
433
|
+
* {@link AppError} is mapped to an HTTP response by the \`AppErrorHandler\`.
|
|
434
|
+
*/
|
|
435
|
+
protected readonly error = AppError;
|
|
436
|
+
}
|
|
437
|
+
`;C(P,E);let L=`// AUTO-GENERATED by frs \u2014 do not edit.
|
|
246
438
|
// Run \`frs gen --root ${o}\` to refresh.
|
|
247
439
|
|
|
248
440
|
import type { AnyRouteDef } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
249
441
|
|
|
250
442
|
export const routes: AnyRouteDef[] = [];
|
|
251
|
-
|
|
443
|
+
`;C(F,L);let U=ve({root:o,apisFile:i,servicesFile:a,apis:c});x.push(U);let H=R(dirname(p),p),N=R(dirname(p),F),O=c.length===1?`export const { ${c[0]} } = apis.toFunctions(routes, onRequest, {`:`export const { ${c.join(", ")} } = apis.toFunctions(routes, onRequest, {`;for(let u of x)console.log(`[frs] wrote ${u}`);for(let u of A)console.log(`[frs] skipped ${u} (use --force to overwrite)`);console.log(`
|
|
252
444
|
Next steps:
|
|
253
445
|
|
|
254
446
|
1. Wire the registry in your Functions entrypoint (e.g. src/index.ts):
|
|
255
447
|
|
|
256
448
|
import { onRequest } from "firebase-functions/v2/https";
|
|
257
|
-
import { apis } from "${
|
|
258
|
-
import { routes } from "${
|
|
449
|
+
import { apis } from "${H}";
|
|
450
|
+
import { routes } from "${N}";
|
|
259
451
|
|
|
260
|
-
${
|
|
452
|
+
${O}
|
|
261
453
|
defaults: { region: "us-central1", invoker: "public" },
|
|
262
454
|
});
|
|
263
455
|
|
|
264
456
|
2. Scaffold a first route:
|
|
265
457
|
|
|
266
|
-
frs new createPost --domain posts --method post --api ${
|
|
458
|
+
frs new createPost --domain posts --method post --api ${c[0]}
|
|
267
459
|
|
|
268
460
|
3. Refresh the manifest before each build:
|
|
269
461
|
|
|
270
462
|
frs gen --root ${o}
|
|
271
|
-
`);}finally{s.close();}}function
|
|
463
|
+
`);}finally{s.close();}}function R(e,r){let s=relative(e,r).replace(/\\/g,"/");return s=s.replace(/\.ts$/,".js"),s.startsWith(".")||(s=`./${s}`),s}async function be(e,r,s){e!=="service"&&(console.error(`[frs] unknown "add" target: ${e??"(missing)"} \u2014 supported: service`),process.exit(2)),r||(console.error("[frs] service name is required: frs add service <name>"),process.exit(2));let t=s.force===true,o=z(),a=[m(s["services-file"]),o.servicesFile,"src/services.ts","services.ts"].filter(w=>typeof w=="string"&&w.length>0),n;for(let w of a){let E=resolve(process.cwd(),w);if(existsSync(E)){n=E;break}}if(!n){let w=a.map(E=>resolve(process.cwd(),E)).join(`
|
|
272
464
|
`);console.error(`[frs] services file not found. Tried:
|
|
273
|
-
${
|
|
274
|
-
Run \`frs init\` first or pass --services-file <path>.`),process.exit(2);}let
|
|
465
|
+
${w}
|
|
466
|
+
Run \`frs init\` first or pass --services-file <path>.`),process.exit(2);}let c=m(s["services-dir"])??o.servicesDir??resolve(dirname(n),"services"),f=resolve(process.cwd(),c);mkdirSync(f,{recursive:true});let g=`${r.charAt(0).toUpperCase()}${r.slice(1)}Service`,p=resolve(f,`${r}.ts`),b=`import type { RequestContext } from "@lpdjs/firestore-repo-service/servers/hono";
|
|
275
467
|
|
|
276
468
|
/**
|
|
277
|
-
* ${
|
|
469
|
+
* ${g} \u2014 generated by \`frs add service ${r}\`.
|
|
278
470
|
*
|
|
279
471
|
* Registered with a **factory** in \`services.ts\` so dependencies are
|
|
280
472
|
* destructured at registration time. Add new constructor parameters here
|
|
281
|
-
* and update the factory line (\`({ ctx, otherSvc }) => new ${
|
|
473
|
+
* and update the factory line (\`({ ctx, otherSvc }) => new ${g}(ctx, otherSvc)\`)
|
|
282
474
|
* \u2014 TypeScript will tell you when something is missing.
|
|
283
475
|
*
|
|
284
476
|
* Async resources (DB connections, SDK clients) should stay lazy-loaded
|
|
@@ -292,17 +484,17 @@ Next steps:
|
|
|
292
484
|
* }
|
|
293
485
|
* \`\`\`
|
|
294
486
|
*/
|
|
295
|
-
export class ${
|
|
487
|
+
export class ${g} {
|
|
296
488
|
// eslint-disable-next-line @typescript-eslint/no-useless-constructor
|
|
297
489
|
constructor(private readonly ctx: RequestContext) {}
|
|
298
490
|
|
|
299
491
|
hello(): string {
|
|
300
|
-
return \`hello from ${
|
|
492
|
+
return \`hello from ${r} \u2014 user=\${this.ctx.maybeC?.get("user")?.id ?? "anonymous"}\`;
|
|
301
493
|
}
|
|
302
494
|
}
|
|
303
|
-
`;existsSync(p)&&!
|
|
304
|
-
`),
|
|
305
|
-
`),
|
|
306
|
-
`+
|
|
307
|
-
`),
|
|
495
|
+
`;existsSync(p)&&!t?console.log(`[frs] skipped ${p} (use --force to overwrite)`):(writeFileSync(p,b,"utf8"),console.log(`[frs] wrote ${p}`));let h=readFileSync(n,"utf8"),P=R(dirname(n),p),y=`import { ${g} } from "${P}";`,F=` ${r}: ({ ctx }) => new ${g}(ctx),`;if(h.includes(y)){console.log(`[frs] services.ts already registers "${r}" \u2014 skipping.`);return}let x=h.split(`
|
|
496
|
+
`),A=-1;for(let w=0;w<x.length;w++)/^import\s/.test(x[w])&&(A=w);A>=0?x.splice(A+1,0,y):x.unshift(y);let C=x.join(`
|
|
497
|
+
`),k=C.match(/createServices\s*\(\s*\{/);if(!k){console.error(`[frs] could not find \`createServices({\` in ${n} \u2014 register "${r}" manually.`);return}let $=k.index+k[0].length,D=C.slice(0,$)+`
|
|
498
|
+
`+F+C.slice($);writeFileSync(n,D,"utf8"),console.log(`[frs] updated ${n} (+ ${r})`);}function Pe(e,r){let s=["apis.ts","apis.js","api.ts","api.js"],t=[e,dirname(e),dirname(dirname(e))];for(let o of t)for(let i of s){let a=resolve(o,i);if(existsSync(a)){let n=relative(r,a).replace(/\\/g,"/");return n=n.replace(/\.ts$/,".js").replace(/\.js$/,".js"),n.startsWith(".")||(n=`./${n}`),n}}return "../../../../apis.js"}function Ce(e,r){let s=["services.ts","services.js"],t=[e,dirname(e),dirname(dirname(e))];for(let o of t)for(let i of s){let a=resolve(o,i);if(existsSync(a)){let n=relative(r,a).replace(/\\/g,"/");return n=n.replace(/\.ts$/,".js"),n.startsWith(".")||(n=`./${n}`),n}}return "../../../../services.js"}function Se(e,r){let s=["base-usecase.ts","base-usecase.js"],t=[e,dirname(e),dirname(dirname(e))];for(let o of t)for(let i of s){let a=resolve(o,i);if(existsSync(a)){let n=relative(r,a).replace(/\\/g,"/");return n=n.replace(/\.ts$/,".js"),n.startsWith(".")||(n=`./${n}`),n}}return "../../../../base-usecase.js"}async function Ae(){let e=process.argv.slice(2),{command:r,flags:s}=ye(e);switch(r){case "init":await $e(s);return;case "gen":await xe(s);return;case "new":await we(e[1],s);return;case "add":await be(e[1],e[2],s);return;case "help":case "--help":case "-h":X();return;default:console.error(`[frs] unknown command: ${r}
|
|
499
|
+
`),X(),process.exit(2);}}Ae().catch(e=>{console.error(e),process.exit(1);});//# sourceMappingURL=cli.js.map
|
|
308
500
|
//# sourceMappingURL=cli.js.map
|