sst-http 0.1.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/LICENSE +21 -0
- package/README.md +157 -0
- package/dist/cli.cjs +262 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +265 -0
- package/dist/index.cjs +558 -0
- package/dist/index.d.cts +42 -0
- package/dist/index.d.ts +42 -0
- package/dist/index.js +512 -0
- package/dist/infra.cjs +230 -0
- package/dist/infra.d.cts +61 -0
- package/dist/infra.d.ts +61 -0
- package/dist/infra.js +202 -0
- package/dist/types-D69iuoxv.d.cts +54 -0
- package/dist/types-D69iuoxv.d.ts +54 -0
- package/package.json +79 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
# sst-http
|
|
2
|
+
|
|
3
|
+
Decorator-based HTTP routing for [SST v3](https://sst.dev) that keeps your app on a single Lambda handler while still wiring routes directly into API Gateway. Build routes with NestJS-style decorators, secure them with Firebase JWT authorizers, and generate an infra-ready manifest from your source.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add sst-http
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Define Routes
|
|
12
|
+
|
|
13
|
+
Create routed functions anywhere in your project – no controller classes required.
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
// src/routes/users.ts
|
|
17
|
+
import { Get, Post, FirebaseAuth, json } from "sst-http";
|
|
18
|
+
|
|
19
|
+
export class UserRoutes {
|
|
20
|
+
@Get("/users/{id}")
|
|
21
|
+
@FirebaseAuth()
|
|
22
|
+
static async getUser({ params }: { params: { id: string } }) {
|
|
23
|
+
return json(200, { id: params.id });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
@Post("/users")
|
|
27
|
+
@FirebaseAuth({ optional: false })
|
|
28
|
+
static async createUser({ body }: { body: { email: string } }) {
|
|
29
|
+
return json(201, { ok: true, email: body.email });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export const getUser = UserRoutes.getUser;
|
|
34
|
+
export const createUser = UserRoutes.createUser;
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Enable name-based inference once at bootstrap if you prefer omitting explicit path strings:
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
import { configureRoutes } from "sst-http";
|
|
41
|
+
|
|
42
|
+
configureRoutes({ inferPathFromName: true });
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Parameter decorators are available when you want granular control:
|
|
46
|
+
|
|
47
|
+
```ts
|
|
48
|
+
import { Body, Post } from "sst-http";
|
|
49
|
+
import { z } from "zod";
|
|
50
|
+
|
|
51
|
+
const CreateTodo = z.object({ title: z.string().min(1) });
|
|
52
|
+
|
|
53
|
+
export class TodoRoutes {
|
|
54
|
+
@Post("/todos")
|
|
55
|
+
static createTodo(@Body(CreateTodo) payload: z.infer<typeof CreateTodo>) {
|
|
56
|
+
// payload is validated JSON
|
|
57
|
+
return { statusCode: 201, body: JSON.stringify(payload) };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const createTodo = TodoRoutes.createTodo;
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
> **Note**
|
|
65
|
+
> API Gateway route keys expect `{param}` placeholders. The router accepts either `{param}` or `:param` at runtime, but manifests and infra wiring emit `{param}` so your deployed routes line up with AWS.
|
|
66
|
+
|
|
67
|
+
## Single Lambda Entry
|
|
68
|
+
|
|
69
|
+
All decorated modules register themselves on import. The single exported handler performs routing and response formatting for both REST and HTTP API Gateway payloads.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
// src/server.ts
|
|
73
|
+
import "reflect-metadata";
|
|
74
|
+
import { createHandler } from "sst-http";
|
|
75
|
+
|
|
76
|
+
import "./routes/users";
|
|
77
|
+
import "./routes/health";
|
|
78
|
+
|
|
79
|
+
export const handler = createHandler();
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Helpers such as `json()`, `text()`, and `noContent()` are available for concise responses, and thrown `HttpError` instances are turned into normalized JSON error payloads.
|
|
83
|
+
|
|
84
|
+
## Scan & Manifest
|
|
85
|
+
|
|
86
|
+
Use the CLI to inspect your source tree and materialize a routes manifest for infra wiring.
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
pnpm sst-http scan --glob "src/routes/**/*.ts" --out routes.manifest.json
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Pass `--infer-name` to map routes without explicit paths using the kebab-case function name (matching the runtime `configureRoutes({ inferPathFromName: true })`).
|
|
93
|
+
|
|
94
|
+
## Firebase JWT Authorizer
|
|
95
|
+
|
|
96
|
+
Mark a route with `@FirebaseAuth()` and the manifest records it as protected. The core wiring function sets up an API Gateway JWT authorizer that points at your Firebase project (issuer `https://securetoken.google.com/<projectId>` and matching audience). Optional roles and optional-auth flags flow through to the adapter so you can fine-tune scopes.
|
|
97
|
+
|
|
98
|
+
## Wire API Gateway
|
|
99
|
+
|
|
100
|
+
`sst-http/infra` ships with a manifest-driven wiring utility plus adapters for HTTP API (ApiGatewayV2) and REST API (ApiGateway). The example below uses the HTTP API adapter inside `sst.config.ts`.
|
|
101
|
+
|
|
102
|
+
```ts
|
|
103
|
+
// sst.config.ts
|
|
104
|
+
export default $config({
|
|
105
|
+
app() {
|
|
106
|
+
return { name: "sst-http-demo", home: "aws" };
|
|
107
|
+
},
|
|
108
|
+
async run() {
|
|
109
|
+
const {
|
|
110
|
+
loadRoutesManifest,
|
|
111
|
+
wireApiFromManifest,
|
|
112
|
+
httpApiAdapter,
|
|
113
|
+
} = await import("sst-http/infra");
|
|
114
|
+
|
|
115
|
+
const manifest = loadRoutesManifest("routes.manifest.json");
|
|
116
|
+
const { api, registerRoute, ensureJwtAuthorizer } = httpApiAdapter({
|
|
117
|
+
apiName: "Api",
|
|
118
|
+
apiArgs: {
|
|
119
|
+
transform: {
|
|
120
|
+
route: {
|
|
121
|
+
handler: {
|
|
122
|
+
runtime: "nodejs20.x",
|
|
123
|
+
timeout: "10 seconds",
|
|
124
|
+
memory: "512 MB",
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
wireApiFromManifest(manifest, {
|
|
132
|
+
handlerFile: "src/server.handler",
|
|
133
|
+
firebaseProjectId: process.env.FIREBASE_PROJECT_ID!,
|
|
134
|
+
registerRoute,
|
|
135
|
+
ensureJwtAuthorizer,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
return { ApiUrl: api.url };
|
|
139
|
+
},
|
|
140
|
+
});
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Swap in `restApiAdapter` if you prefer API Gateway REST APIs—the wiring contract is identical.
|
|
144
|
+
|
|
145
|
+
## Publishing
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
npm login
|
|
149
|
+
npm version patch
|
|
150
|
+
pnpm run release
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The release script builds the ESM/CJS bundles via `tsup` before publishing.
|
|
154
|
+
|
|
155
|
+
## License
|
|
156
|
+
|
|
157
|
+
MIT
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
// src/cli.ts
|
|
5
|
+
var import_node_fs = require("fs");
|
|
6
|
+
var import_node_path = require("path");
|
|
7
|
+
var import_node_process = require("process");
|
|
8
|
+
var import_ts_morph = require("ts-morph");
|
|
9
|
+
var METHOD_DECORATORS = {
|
|
10
|
+
Get: "GET",
|
|
11
|
+
Post: "POST",
|
|
12
|
+
Put: "PUT",
|
|
13
|
+
Patch: "PATCH",
|
|
14
|
+
Delete: "DELETE",
|
|
15
|
+
Head: "HEAD",
|
|
16
|
+
Options: "OPTIONS"
|
|
17
|
+
};
|
|
18
|
+
async function main() {
|
|
19
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
20
|
+
if (!command || command === "--help" || command === "-h") {
|
|
21
|
+
printUsage();
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
switch (command) {
|
|
25
|
+
case "scan": {
|
|
26
|
+
await runScan(rest);
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
default: {
|
|
30
|
+
import_node_process.stderr.write(`Unknown command: ${command}
|
|
31
|
+
`);
|
|
32
|
+
printUsage();
|
|
33
|
+
process.exitCode = 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
async function runScan(args) {
|
|
38
|
+
const options = parseScanArgs(args);
|
|
39
|
+
const project = options.project ? new import_ts_morph.Project({ tsConfigFilePath: options.project }) : new import_ts_morph.Project();
|
|
40
|
+
if (options.globs.length === 0) {
|
|
41
|
+
options.globs.push("src/**/*.ts");
|
|
42
|
+
}
|
|
43
|
+
project.addSourceFilesAtPaths(options.globs);
|
|
44
|
+
const manifest = { routes: [] };
|
|
45
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
46
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
47
|
+
if (!fn.isExported() && !fn.isDefaultExport()) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const route = extractRoute(fn, options.inferName);
|
|
51
|
+
if (route) {
|
|
52
|
+
manifest.routes.push(route);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (const cls of sourceFile.getClasses()) {
|
|
56
|
+
if (!cls.isExported() && !cls.isDefaultExport()) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
for (const method of cls.getMethods()) {
|
|
60
|
+
if (!method.isStatic()) {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
const route = extractRoute(method, options.inferName);
|
|
64
|
+
if (route) {
|
|
65
|
+
manifest.routes.push(route);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
manifest.routes.sort((a, b) => {
|
|
71
|
+
if (a.path === b.path) {
|
|
72
|
+
return a.method.localeCompare(b.method);
|
|
73
|
+
}
|
|
74
|
+
return a.path.localeCompare(b.path);
|
|
75
|
+
});
|
|
76
|
+
const outPath = (0, import_node_path.resolve)((0, import_node_process.cwd)(), options.outFile);
|
|
77
|
+
(0, import_node_fs.writeFileSync)(outPath, JSON.stringify(manifest, null, 2));
|
|
78
|
+
import_node_process.stdout.write(`Wrote ${manifest.routes.length} route(s) to ${outPath}
|
|
79
|
+
`);
|
|
80
|
+
}
|
|
81
|
+
function parseScanArgs(args) {
|
|
82
|
+
const result = {
|
|
83
|
+
globs: [],
|
|
84
|
+
outFile: "routes.manifest.json",
|
|
85
|
+
project: void 0,
|
|
86
|
+
inferName: false
|
|
87
|
+
};
|
|
88
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
89
|
+
const value = args[i];
|
|
90
|
+
switch (value) {
|
|
91
|
+
case "--glob":
|
|
92
|
+
case "-g": {
|
|
93
|
+
const glob = args[++i];
|
|
94
|
+
if (!glob) {
|
|
95
|
+
throw new Error("Missing value for --glob");
|
|
96
|
+
}
|
|
97
|
+
result.globs.push(glob);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
case "--out":
|
|
101
|
+
case "-o": {
|
|
102
|
+
const out = args[++i];
|
|
103
|
+
if (!out) {
|
|
104
|
+
throw new Error("Missing value for --out");
|
|
105
|
+
}
|
|
106
|
+
result.outFile = out;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
case "--project":
|
|
110
|
+
case "-p": {
|
|
111
|
+
const project = args[++i];
|
|
112
|
+
if (!project) {
|
|
113
|
+
throw new Error("Missing value for --project");
|
|
114
|
+
}
|
|
115
|
+
result.project = project;
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "--infer-name": {
|
|
119
|
+
result.inferName = true;
|
|
120
|
+
break;
|
|
121
|
+
}
|
|
122
|
+
default: {
|
|
123
|
+
throw new Error(`Unknown option: ${value}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
function extractRoute(fn, inferName) {
|
|
130
|
+
const decorators = collectDecorators(fn);
|
|
131
|
+
const methodDecorators = decorators.filter((decorator) => {
|
|
132
|
+
const name = decorator.getName();
|
|
133
|
+
return Boolean(name && METHOD_DECORATORS[name]);
|
|
134
|
+
});
|
|
135
|
+
if (methodDecorators.length === 0) {
|
|
136
|
+
return void 0;
|
|
137
|
+
}
|
|
138
|
+
if (methodDecorators.length > 1) {
|
|
139
|
+
const funcName = fn.getName() ?? "<anonymous>";
|
|
140
|
+
throw new Error(`Route "${funcName}" has multiple HTTP method decorators.`);
|
|
141
|
+
}
|
|
142
|
+
const methodDecorator = methodDecorators[0];
|
|
143
|
+
const method = METHOD_DECORATORS[methodDecorator.getName() ?? ""];
|
|
144
|
+
const path = readPath(methodDecorator) ?? (inferName ? inferPathFromName(fn) : void 0);
|
|
145
|
+
if (!path) {
|
|
146
|
+
const funcName = fn.getName() ?? "<anonymous>";
|
|
147
|
+
throw new Error(`Route "${funcName}" is missing a path. Pass one to the decorator or enable --infer-name.`);
|
|
148
|
+
}
|
|
149
|
+
const firebaseDecorator = decorators.find((decorator) => decorator.getName() === "FirebaseAuth");
|
|
150
|
+
const auth = firebaseDecorator ? readFirebaseAuth(firebaseDecorator) : { type: "none" };
|
|
151
|
+
return {
|
|
152
|
+
method,
|
|
153
|
+
path,
|
|
154
|
+
auth
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function collectDecorators(fn) {
|
|
158
|
+
if (import_ts_morph.Node.isMethodDeclaration(fn)) {
|
|
159
|
+
return fn.getDecorators();
|
|
160
|
+
}
|
|
161
|
+
return fn.getModifiers().filter(import_ts_morph.Node.isDecorator).map((modifier) => modifier.asKindOrThrow(import_ts_morph.SyntaxKind.Decorator));
|
|
162
|
+
}
|
|
163
|
+
function readPath(decorator) {
|
|
164
|
+
const args = decorator.getArguments();
|
|
165
|
+
if (args.length === 0) {
|
|
166
|
+
return void 0;
|
|
167
|
+
}
|
|
168
|
+
const first = args[0];
|
|
169
|
+
if (import_ts_morph.Node.isStringLiteral(first) || import_ts_morph.Node.isNoSubstitutionTemplateLiteral(first)) {
|
|
170
|
+
const value = first.getLiteralValue();
|
|
171
|
+
if (!value) {
|
|
172
|
+
return "/";
|
|
173
|
+
}
|
|
174
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
175
|
+
}
|
|
176
|
+
throw new Error(`Unsupported path expression: ${first.getText()}`);
|
|
177
|
+
}
|
|
178
|
+
function readFirebaseAuth(decorator) {
|
|
179
|
+
const args = decorator.getArguments();
|
|
180
|
+
if (args.length === 0) {
|
|
181
|
+
return { type: "firebase" };
|
|
182
|
+
}
|
|
183
|
+
const options = args[0];
|
|
184
|
+
if (!import_ts_morph.Node.isObjectLiteralExpression(options)) {
|
|
185
|
+
throw new Error("@FirebaseAuth() only supports object literal options");
|
|
186
|
+
}
|
|
187
|
+
let optional;
|
|
188
|
+
let roles;
|
|
189
|
+
for (const prop of options.getProperties()) {
|
|
190
|
+
if (!import_ts_morph.Node.isPropertyAssignment(prop)) {
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const name = prop.getName();
|
|
194
|
+
const initializer = prop.getInitializer();
|
|
195
|
+
if (!initializer) {
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (name === "optional") {
|
|
199
|
+
if (initializer.getKind() === import_ts_morph.SyntaxKind.TrueKeyword) {
|
|
200
|
+
optional = true;
|
|
201
|
+
} else if (initializer.getKind() === import_ts_morph.SyntaxKind.FalseKeyword) {
|
|
202
|
+
optional = false;
|
|
203
|
+
} else {
|
|
204
|
+
throw new Error("@FirebaseAuth({ optional }) expects a boolean literal");
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
if (name === "roles") {
|
|
208
|
+
if (!import_ts_morph.Node.isArrayLiteralExpression(initializer)) {
|
|
209
|
+
throw new Error("@FirebaseAuth({ roles }) expects an array literal");
|
|
210
|
+
}
|
|
211
|
+
roles = initializer.getElements().map((element) => {
|
|
212
|
+
if (!import_ts_morph.Node.isStringLiteral(element) && !import_ts_morph.Node.isNoSubstitutionTemplateLiteral(element)) {
|
|
213
|
+
throw new Error("@FirebaseAuth roles must be string literals");
|
|
214
|
+
}
|
|
215
|
+
return element.getLiteralValue();
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
const auth = {
|
|
220
|
+
type: "firebase"
|
|
221
|
+
};
|
|
222
|
+
if (typeof optional === "boolean") {
|
|
223
|
+
auth.optional = optional;
|
|
224
|
+
}
|
|
225
|
+
if (roles && roles.length > 0) {
|
|
226
|
+
auth.roles = roles;
|
|
227
|
+
}
|
|
228
|
+
return auth;
|
|
229
|
+
}
|
|
230
|
+
function inferPathFromName(fn) {
|
|
231
|
+
const name = fn.getName();
|
|
232
|
+
if (!name) {
|
|
233
|
+
return void 0;
|
|
234
|
+
}
|
|
235
|
+
const slug = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
|
|
236
|
+
return `/${slug}`;
|
|
237
|
+
}
|
|
238
|
+
function printUsage() {
|
|
239
|
+
import_node_process.stdout.write(`Usage: sst-http <command> [options]
|
|
240
|
+
`);
|
|
241
|
+
import_node_process.stdout.write(`
|
|
242
|
+
Commands:
|
|
243
|
+
`);
|
|
244
|
+
import_node_process.stdout.write(` scan Scan for decorated routes and emit a manifest
|
|
245
|
+
`);
|
|
246
|
+
import_node_process.stdout.write(`
|
|
247
|
+
Options for scan:
|
|
248
|
+
`);
|
|
249
|
+
import_node_process.stdout.write(` --glob, -g Glob pattern to include (repeatable). Defaults to src/**/*.ts
|
|
250
|
+
`);
|
|
251
|
+
import_node_process.stdout.write(` --out, -o Output file for the manifest. Defaults to routes.manifest.json
|
|
252
|
+
`);
|
|
253
|
+
import_node_process.stdout.write(` --project, -p Path to a tsconfig.json used to resolve source files
|
|
254
|
+
`);
|
|
255
|
+
import_node_process.stdout.write(` --infer-name Infer missing paths from function names (kebab-case)
|
|
256
|
+
`);
|
|
257
|
+
}
|
|
258
|
+
main().catch((error) => {
|
|
259
|
+
import_node_process.stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
260
|
+
`);
|
|
261
|
+
process.exitCode = 1;
|
|
262
|
+
});
|
package/dist/cli.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { writeFileSync } from "fs";
|
|
5
|
+
import { resolve } from "path";
|
|
6
|
+
import { cwd, stderr, stdout } from "process";
|
|
7
|
+
import {
|
|
8
|
+
Node,
|
|
9
|
+
Project,
|
|
10
|
+
SyntaxKind
|
|
11
|
+
} from "ts-morph";
|
|
12
|
+
var METHOD_DECORATORS = {
|
|
13
|
+
Get: "GET",
|
|
14
|
+
Post: "POST",
|
|
15
|
+
Put: "PUT",
|
|
16
|
+
Patch: "PATCH",
|
|
17
|
+
Delete: "DELETE",
|
|
18
|
+
Head: "HEAD",
|
|
19
|
+
Options: "OPTIONS"
|
|
20
|
+
};
|
|
21
|
+
async function main() {
|
|
22
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
23
|
+
if (!command || command === "--help" || command === "-h") {
|
|
24
|
+
printUsage();
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
switch (command) {
|
|
28
|
+
case "scan": {
|
|
29
|
+
await runScan(rest);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
default: {
|
|
33
|
+
stderr.write(`Unknown command: ${command}
|
|
34
|
+
`);
|
|
35
|
+
printUsage();
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
async function runScan(args) {
|
|
41
|
+
const options = parseScanArgs(args);
|
|
42
|
+
const project = options.project ? new Project({ tsConfigFilePath: options.project }) : new Project();
|
|
43
|
+
if (options.globs.length === 0) {
|
|
44
|
+
options.globs.push("src/**/*.ts");
|
|
45
|
+
}
|
|
46
|
+
project.addSourceFilesAtPaths(options.globs);
|
|
47
|
+
const manifest = { routes: [] };
|
|
48
|
+
for (const sourceFile of project.getSourceFiles()) {
|
|
49
|
+
for (const fn of sourceFile.getFunctions()) {
|
|
50
|
+
if (!fn.isExported() && !fn.isDefaultExport()) {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
const route = extractRoute(fn, options.inferName);
|
|
54
|
+
if (route) {
|
|
55
|
+
manifest.routes.push(route);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
for (const cls of sourceFile.getClasses()) {
|
|
59
|
+
if (!cls.isExported() && !cls.isDefaultExport()) {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
for (const method of cls.getMethods()) {
|
|
63
|
+
if (!method.isStatic()) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const route = extractRoute(method, options.inferName);
|
|
67
|
+
if (route) {
|
|
68
|
+
manifest.routes.push(route);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
manifest.routes.sort((a, b) => {
|
|
74
|
+
if (a.path === b.path) {
|
|
75
|
+
return a.method.localeCompare(b.method);
|
|
76
|
+
}
|
|
77
|
+
return a.path.localeCompare(b.path);
|
|
78
|
+
});
|
|
79
|
+
const outPath = resolve(cwd(), options.outFile);
|
|
80
|
+
writeFileSync(outPath, JSON.stringify(manifest, null, 2));
|
|
81
|
+
stdout.write(`Wrote ${manifest.routes.length} route(s) to ${outPath}
|
|
82
|
+
`);
|
|
83
|
+
}
|
|
84
|
+
function parseScanArgs(args) {
|
|
85
|
+
const result = {
|
|
86
|
+
globs: [],
|
|
87
|
+
outFile: "routes.manifest.json",
|
|
88
|
+
project: void 0,
|
|
89
|
+
inferName: false
|
|
90
|
+
};
|
|
91
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
92
|
+
const value = args[i];
|
|
93
|
+
switch (value) {
|
|
94
|
+
case "--glob":
|
|
95
|
+
case "-g": {
|
|
96
|
+
const glob = args[++i];
|
|
97
|
+
if (!glob) {
|
|
98
|
+
throw new Error("Missing value for --glob");
|
|
99
|
+
}
|
|
100
|
+
result.globs.push(glob);
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
case "--out":
|
|
104
|
+
case "-o": {
|
|
105
|
+
const out = args[++i];
|
|
106
|
+
if (!out) {
|
|
107
|
+
throw new Error("Missing value for --out");
|
|
108
|
+
}
|
|
109
|
+
result.outFile = out;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
case "--project":
|
|
113
|
+
case "-p": {
|
|
114
|
+
const project = args[++i];
|
|
115
|
+
if (!project) {
|
|
116
|
+
throw new Error("Missing value for --project");
|
|
117
|
+
}
|
|
118
|
+
result.project = project;
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
case "--infer-name": {
|
|
122
|
+
result.inferName = true;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
default: {
|
|
126
|
+
throw new Error(`Unknown option: ${value}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return result;
|
|
131
|
+
}
|
|
132
|
+
function extractRoute(fn, inferName) {
|
|
133
|
+
const decorators = collectDecorators(fn);
|
|
134
|
+
const methodDecorators = decorators.filter((decorator) => {
|
|
135
|
+
const name = decorator.getName();
|
|
136
|
+
return Boolean(name && METHOD_DECORATORS[name]);
|
|
137
|
+
});
|
|
138
|
+
if (methodDecorators.length === 0) {
|
|
139
|
+
return void 0;
|
|
140
|
+
}
|
|
141
|
+
if (methodDecorators.length > 1) {
|
|
142
|
+
const funcName = fn.getName() ?? "<anonymous>";
|
|
143
|
+
throw new Error(`Route "${funcName}" has multiple HTTP method decorators.`);
|
|
144
|
+
}
|
|
145
|
+
const methodDecorator = methodDecorators[0];
|
|
146
|
+
const method = METHOD_DECORATORS[methodDecorator.getName() ?? ""];
|
|
147
|
+
const path = readPath(methodDecorator) ?? (inferName ? inferPathFromName(fn) : void 0);
|
|
148
|
+
if (!path) {
|
|
149
|
+
const funcName = fn.getName() ?? "<anonymous>";
|
|
150
|
+
throw new Error(`Route "${funcName}" is missing a path. Pass one to the decorator or enable --infer-name.`);
|
|
151
|
+
}
|
|
152
|
+
const firebaseDecorator = decorators.find((decorator) => decorator.getName() === "FirebaseAuth");
|
|
153
|
+
const auth = firebaseDecorator ? readFirebaseAuth(firebaseDecorator) : { type: "none" };
|
|
154
|
+
return {
|
|
155
|
+
method,
|
|
156
|
+
path,
|
|
157
|
+
auth
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
function collectDecorators(fn) {
|
|
161
|
+
if (Node.isMethodDeclaration(fn)) {
|
|
162
|
+
return fn.getDecorators();
|
|
163
|
+
}
|
|
164
|
+
return fn.getModifiers().filter(Node.isDecorator).map((modifier) => modifier.asKindOrThrow(SyntaxKind.Decorator));
|
|
165
|
+
}
|
|
166
|
+
function readPath(decorator) {
|
|
167
|
+
const args = decorator.getArguments();
|
|
168
|
+
if (args.length === 0) {
|
|
169
|
+
return void 0;
|
|
170
|
+
}
|
|
171
|
+
const first = args[0];
|
|
172
|
+
if (Node.isStringLiteral(first) || Node.isNoSubstitutionTemplateLiteral(first)) {
|
|
173
|
+
const value = first.getLiteralValue();
|
|
174
|
+
if (!value) {
|
|
175
|
+
return "/";
|
|
176
|
+
}
|
|
177
|
+
return value.startsWith("/") ? value : `/${value}`;
|
|
178
|
+
}
|
|
179
|
+
throw new Error(`Unsupported path expression: ${first.getText()}`);
|
|
180
|
+
}
|
|
181
|
+
function readFirebaseAuth(decorator) {
|
|
182
|
+
const args = decorator.getArguments();
|
|
183
|
+
if (args.length === 0) {
|
|
184
|
+
return { type: "firebase" };
|
|
185
|
+
}
|
|
186
|
+
const options = args[0];
|
|
187
|
+
if (!Node.isObjectLiteralExpression(options)) {
|
|
188
|
+
throw new Error("@FirebaseAuth() only supports object literal options");
|
|
189
|
+
}
|
|
190
|
+
let optional;
|
|
191
|
+
let roles;
|
|
192
|
+
for (const prop of options.getProperties()) {
|
|
193
|
+
if (!Node.isPropertyAssignment(prop)) {
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const name = prop.getName();
|
|
197
|
+
const initializer = prop.getInitializer();
|
|
198
|
+
if (!initializer) {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
if (name === "optional") {
|
|
202
|
+
if (initializer.getKind() === SyntaxKind.TrueKeyword) {
|
|
203
|
+
optional = true;
|
|
204
|
+
} else if (initializer.getKind() === SyntaxKind.FalseKeyword) {
|
|
205
|
+
optional = false;
|
|
206
|
+
} else {
|
|
207
|
+
throw new Error("@FirebaseAuth({ optional }) expects a boolean literal");
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (name === "roles") {
|
|
211
|
+
if (!Node.isArrayLiteralExpression(initializer)) {
|
|
212
|
+
throw new Error("@FirebaseAuth({ roles }) expects an array literal");
|
|
213
|
+
}
|
|
214
|
+
roles = initializer.getElements().map((element) => {
|
|
215
|
+
if (!Node.isStringLiteral(element) && !Node.isNoSubstitutionTemplateLiteral(element)) {
|
|
216
|
+
throw new Error("@FirebaseAuth roles must be string literals");
|
|
217
|
+
}
|
|
218
|
+
return element.getLiteralValue();
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
const auth = {
|
|
223
|
+
type: "firebase"
|
|
224
|
+
};
|
|
225
|
+
if (typeof optional === "boolean") {
|
|
226
|
+
auth.optional = optional;
|
|
227
|
+
}
|
|
228
|
+
if (roles && roles.length > 0) {
|
|
229
|
+
auth.roles = roles;
|
|
230
|
+
}
|
|
231
|
+
return auth;
|
|
232
|
+
}
|
|
233
|
+
function inferPathFromName(fn) {
|
|
234
|
+
const name = fn.getName();
|
|
235
|
+
if (!name) {
|
|
236
|
+
return void 0;
|
|
237
|
+
}
|
|
238
|
+
const slug = name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").replace(/[_\s]+/g, "-").toLowerCase();
|
|
239
|
+
return `/${slug}`;
|
|
240
|
+
}
|
|
241
|
+
function printUsage() {
|
|
242
|
+
stdout.write(`Usage: sst-http <command> [options]
|
|
243
|
+
`);
|
|
244
|
+
stdout.write(`
|
|
245
|
+
Commands:
|
|
246
|
+
`);
|
|
247
|
+
stdout.write(` scan Scan for decorated routes and emit a manifest
|
|
248
|
+
`);
|
|
249
|
+
stdout.write(`
|
|
250
|
+
Options for scan:
|
|
251
|
+
`);
|
|
252
|
+
stdout.write(` --glob, -g Glob pattern to include (repeatable). Defaults to src/**/*.ts
|
|
253
|
+
`);
|
|
254
|
+
stdout.write(` --out, -o Output file for the manifest. Defaults to routes.manifest.json
|
|
255
|
+
`);
|
|
256
|
+
stdout.write(` --project, -p Path to a tsconfig.json used to resolve source files
|
|
257
|
+
`);
|
|
258
|
+
stdout.write(` --infer-name Infer missing paths from function names (kebab-case)
|
|
259
|
+
`);
|
|
260
|
+
}
|
|
261
|
+
main().catch((error) => {
|
|
262
|
+
stderr.write(`${error instanceof Error ? error.message : String(error)}
|
|
263
|
+
`);
|
|
264
|
+
process.exitCode = 1;
|
|
265
|
+
});
|