@c9up/ream 0.1.11 → 0.1.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/Ignitor.d.ts.map +1 -1
- package/dist/Ignitor.js +2 -25
- package/dist/Ignitor.js.map +1 -1
- package/dist/env/Env.d.ts +57 -0
- package/dist/env/Env.d.ts.map +1 -0
- package/dist/env/Env.js +52 -0
- package/dist/env/Env.js.map +1 -0
- package/dist/env/loadEnvFiles.d.ts +17 -0
- package/dist/env/loadEnvFiles.d.ts.map +1 -0
- package/dist/env/loadEnvFiles.js +40 -0
- package/dist/env/loadEnvFiles.js.map +1 -0
- package/dist/env/schema.d.ts +48 -0
- package/dist/env/schema.d.ts.map +1 -0
- package/dist/env/schema.js +130 -0
- package/dist/env/schema.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/testing/RequestBuilder.d.ts +21 -0
- package/dist/testing/RequestBuilder.d.ts.map +1 -1
- package/dist/testing/RequestBuilder.js +57 -0
- package/dist/testing/RequestBuilder.js.map +1 -1
- package/events.win32-x64-msvc.node +0 -0
- package/index.win32-x64-msvc.node +0 -0
- package/package.json +1 -1
- package/scheduler.win32-x64-msvc.node +0 -0
- package/src/Ignitor.ts +2 -25
- package/src/env/Env.ts +91 -0
- package/src/env/loadEnvFiles.ts +40 -0
- package/src/env/schema.ts +163 -0
- package/src/index.ts +8 -0
- package/src/testing/RequestBuilder.ts +86 -0
|
@@ -33,6 +33,13 @@ export type HttpSender = (method: HttpMethod, path: string, init: {
|
|
|
33
33
|
headers: Record<string, string>;
|
|
34
34
|
body: Buffer;
|
|
35
35
|
}) => Promise<TestResponse>;
|
|
36
|
+
/** Options for a multipart file part — mirrors Adonis/japa's `.file()` options. */
|
|
37
|
+
export interface FilePart {
|
|
38
|
+
/** Filename advertised in the part's `Content-Disposition`. Defaults to the field name. */
|
|
39
|
+
filename?: string;
|
|
40
|
+
/** MIME type of the part. Defaults to `application/octet-stream`. */
|
|
41
|
+
contentType?: string;
|
|
42
|
+
}
|
|
36
43
|
export declare class RequestBuilder {
|
|
37
44
|
#private;
|
|
38
45
|
constructor(sender: HttpSender, method: HttpMethod, path: string, authStrategy?: AuthStrategy | null);
|
|
@@ -41,6 +48,20 @@ export declare class RequestBuilder {
|
|
|
41
48
|
json(data: unknown): this;
|
|
42
49
|
body(data: Buffer | string, contentType?: string): this;
|
|
43
50
|
form(data: Record<string, string>): this;
|
|
51
|
+
/**
|
|
52
|
+
* Add a `multipart/form-data` text field — mirrors Adonis/japa's `.field()`.
|
|
53
|
+
* Calling `.field()` or `.file()` switches the body to multipart (use
|
|
54
|
+
* `.form()` for `application/x-www-form-urlencoded`). The boundary +
|
|
55
|
+
* `Content-Type` header are produced at send time.
|
|
56
|
+
*/
|
|
57
|
+
field(name: string, value: string): this;
|
|
58
|
+
/**
|
|
59
|
+
* Attach a file as a `multipart/form-data` part — mirrors Adonis/japa's
|
|
60
|
+
* `.file(field, contents, { filename, contentType })`. `contents` is a
|
|
61
|
+
* Buffer (binary) or a string (encoded as utf8). Server-side
|
|
62
|
+
* `request.file(field, …)` reads the resulting part.
|
|
63
|
+
*/
|
|
64
|
+
file(field: string, contents: Buffer | string, options?: FilePart): this;
|
|
44
65
|
cookies(map: Record<string, string>): this;
|
|
45
66
|
cookie(name: string, value: string): this;
|
|
46
67
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RequestBuilder.d.ts","sourceRoot":"","sources":["../../src/testing/RequestBuilder.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAA;AAEnD;;;;;;;;;;;;GAYG;AAEH,MAAM,WAAW,WAAW;IAC1B,qFAAqF;IACrF,EAAE,EAAE,MAAM,GAAG,MAAM,CAAA;IACnB,0EAA0E;IAC1E,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CACtC;AAED,MAAM,WAAW,YAAY;IAC3B,8FAA8F;IAC9F,UAAU,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;IAC1F,oDAAoD;IACpD,UAAU,CAAC,CAAC,OAAO,EAAE,WAAW,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAAA;CAC5F;AAUD,MAAM,MAAM,UAAU,GAAG,KAAK,GAAG,MAAM,GAAG,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAA;AAEpE;;;GAGG;AACH,MAAM,MAAM,UAAU,GAAG,CACvB,MAAM,EAAE,UAAU,EAClB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE;IACJ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;IAC/B,IAAI,EAAE,MAAM,CAAA;CACb,KACE,OAAO,CAAC,YAAY,CAAC,CAAA;AAE1B,qBAAa,cAAc;;
|
|
1
|
+
{"version":3,"file":"RequestBuilder.d.ts","sourceRoot":"","sources":["../../src/testing/RequestBuilder.ts"],"names":[],"mappings":"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"}
|
|
@@ -11,6 +11,7 @@ export class RequestBuilder {
|
|
|
11
11
|
#path;
|
|
12
12
|
#headers = {};
|
|
13
13
|
#body = Buffer.alloc(0);
|
|
14
|
+
#multipart = [];
|
|
14
15
|
#cookies = {};
|
|
15
16
|
#authStrategy;
|
|
16
17
|
#pendingAuth = null;
|
|
@@ -48,6 +49,32 @@ export class RequestBuilder {
|
|
|
48
49
|
this.#headers['content-type'] = 'application/x-www-form-urlencoded';
|
|
49
50
|
return this;
|
|
50
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Add a `multipart/form-data` text field — mirrors Adonis/japa's `.field()`.
|
|
54
|
+
* Calling `.field()` or `.file()` switches the body to multipart (use
|
|
55
|
+
* `.form()` for `application/x-www-form-urlencoded`). The boundary +
|
|
56
|
+
* `Content-Type` header are produced at send time.
|
|
57
|
+
*/
|
|
58
|
+
field(name, value) {
|
|
59
|
+
this.#multipart.push({ kind: 'field', name, value });
|
|
60
|
+
return this;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Attach a file as a `multipart/form-data` part — mirrors Adonis/japa's
|
|
64
|
+
* `.file(field, contents, { filename, contentType })`. `contents` is a
|
|
65
|
+
* Buffer (binary) or a string (encoded as utf8). Server-side
|
|
66
|
+
* `request.file(field, …)` reads the resulting part.
|
|
67
|
+
*/
|
|
68
|
+
file(field, contents, options = {}) {
|
|
69
|
+
this.#multipart.push({
|
|
70
|
+
kind: 'file',
|
|
71
|
+
name: field,
|
|
72
|
+
filename: options.filename ?? field,
|
|
73
|
+
contentType: options.contentType ?? 'application/octet-stream',
|
|
74
|
+
content: typeof contents === 'string' ? Buffer.from(contents, 'utf8') : contents,
|
|
75
|
+
});
|
|
76
|
+
return this;
|
|
77
|
+
}
|
|
51
78
|
cookies(map) {
|
|
52
79
|
Object.assign(this.#cookies, map);
|
|
53
80
|
return this;
|
|
@@ -157,6 +184,13 @@ export class RequestBuilder {
|
|
|
157
184
|
}
|
|
158
185
|
}
|
|
159
186
|
}
|
|
187
|
+
// Multipart parts (.field()/.file()) take precedence over any body set by
|
|
188
|
+
// json()/form()/body(); encode them with a fresh boundary at send time.
|
|
189
|
+
if (this.#multipart.length > 0) {
|
|
190
|
+
const boundary = `----ReamRequestBuilder${crypto.randomUUID().replace(/-/g, '')}`;
|
|
191
|
+
this.#body = encodeMultipart(this.#multipart, boundary);
|
|
192
|
+
this.#headers['content-type'] = `multipart/form-data; boundary=${boundary}`;
|
|
193
|
+
}
|
|
160
194
|
// Serialise cookies into a single `Cookie:` header.
|
|
161
195
|
const cookieEntries = Object.entries(this.#cookies);
|
|
162
196
|
if (cookieEntries.length > 0) {
|
|
@@ -171,6 +205,29 @@ export class RequestBuilder {
|
|
|
171
205
|
function capBody(s, max = 512) {
|
|
172
206
|
return s.length <= max ? s : `${s.slice(0, max)}...[truncated]`;
|
|
173
207
|
}
|
|
208
|
+
/** Strip CR/LF and escape quotes so a name/filename can't break the headers. */
|
|
209
|
+
function sanitizeHeaderParam(value) {
|
|
210
|
+
return value.replace(/[\r\n]/g, '').replace(/"/g, '%22');
|
|
211
|
+
}
|
|
212
|
+
/** Encode multipart/form-data parts (RFC 7578) into a single body Buffer. */
|
|
213
|
+
function encodeMultipart(parts, boundary) {
|
|
214
|
+
const CRLF = '\r\n';
|
|
215
|
+
const chunks = [];
|
|
216
|
+
for (const part of parts) {
|
|
217
|
+
if (part.kind === 'field') {
|
|
218
|
+
chunks.push(Buffer.from(`--${boundary}${CRLF}` +
|
|
219
|
+
`Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"${CRLF}${CRLF}` +
|
|
220
|
+
`${part.value}${CRLF}`, 'utf8'));
|
|
221
|
+
}
|
|
222
|
+
else {
|
|
223
|
+
chunks.push(Buffer.from(`--${boundary}${CRLF}` +
|
|
224
|
+
`Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"; filename="${sanitizeHeaderParam(part.filename)}"${CRLF}` +
|
|
225
|
+
`Content-Type: ${part.contentType}${CRLF}${CRLF}`, 'utf8'), part.content, Buffer.from(CRLF, 'utf8'));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
chunks.push(Buffer.from(`--${boundary}--${CRLF}`, 'utf8'));
|
|
229
|
+
return Buffer.concat(chunks);
|
|
230
|
+
}
|
|
174
231
|
/** Narrowing guard — a non-null, non-array object usable as a string map. */
|
|
175
232
|
function isRecord(x) {
|
|
176
233
|
return typeof x === 'object' && x !== null && !Array.isArray(x);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RequestBuilder.js","sourceRoot":"","sources":["../../src/testing/RequestBuilder.ts"],"names":[],"mappings":"AA8BA,wEAAwE;AACxE,MAAM,gBAAiB,SAAQ,KAAK;IAClC,YAAY,OAAe;QACzB,KAAK,CAAC,OAAO,CAAC,CAAA;QACd,IAAI,CAAC,IAAI,GAAG,kBAAkB,CAAA;IAChC,CAAC;CACF;
|
|
1
|
+
{"version":3,"file":"RequestBuilder.js","sourceRoot":"","sources":["../../src/testing/RequestBuilder.ts"],"names":[],"mappings":"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"}
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
Binary file
|
package/src/Ignitor.ts
CHANGED
|
@@ -13,12 +13,10 @@
|
|
|
13
13
|
* @implements FR17, FR20, FR23
|
|
14
14
|
*/
|
|
15
15
|
|
|
16
|
-
import { readFileSync } from 'node:fs'
|
|
17
|
-
import { fileURLToPath } from 'node:url'
|
|
18
|
-
import { parseEnv } from 'node:util'
|
|
19
16
|
import { Application } from './Application.js'
|
|
20
17
|
import type { ErrorEvent } from './ErrorBoundary.js'
|
|
21
18
|
import { ErrorBoundary } from './ErrorBoundary.js'
|
|
19
|
+
import { loadEnvFiles } from './env/loadEnvFiles.js'
|
|
22
20
|
import { ReamError } from './errors/ReamError.js'
|
|
23
21
|
import type { Emitter } from './events/Emitter.js'
|
|
24
22
|
import { startHotReload } from './HotReload.js'
|
|
@@ -302,28 +300,7 @@ export class Ignitor {
|
|
|
302
300
|
*/
|
|
303
301
|
#loadEnvironmentFiles(): void {
|
|
304
302
|
if (!this.appRoot) return
|
|
305
|
-
|
|
306
|
-
// Most-specific first; with "existing wins", earlier files take precedence.
|
|
307
|
-
const files = [
|
|
308
|
-
nodeEnv ? `.env.${nodeEnv}.local` : null,
|
|
309
|
-
this.environment === 'test' ? null : '.env.local',
|
|
310
|
-
nodeEnv ? `.env.${nodeEnv}` : null,
|
|
311
|
-
'.env',
|
|
312
|
-
].filter((name): name is string => name !== null)
|
|
313
|
-
|
|
314
|
-
for (const name of files) {
|
|
315
|
-
let contents: string
|
|
316
|
-
try {
|
|
317
|
-
contents = readFileSync(fileURLToPath(new URL(name, this.appRoot)), 'utf8')
|
|
318
|
-
} catch {
|
|
319
|
-
continue // file absent — nothing to load
|
|
320
|
-
}
|
|
321
|
-
for (const [key, value] of Object.entries(parseEnv(contents))) {
|
|
322
|
-
if (typeof value === 'string' && process.env[key] === undefined) {
|
|
323
|
-
process.env[key] = value
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
}
|
|
303
|
+
loadEnvFiles(this.appRoot, { skipEnvLocal: this.environment === 'test' })
|
|
327
304
|
}
|
|
328
305
|
|
|
329
306
|
private async phaseRegister(): Promise<void> {
|
package/src/env/Env.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { loadEnvFiles } from './loadEnvFiles.js'
|
|
2
|
+
import { type SchemaNode, schema } from './schema.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Type-safe environment access — mirrors `@adonisjs/core/env`:
|
|
6
|
+
*
|
|
7
|
+
* // start/env.ts
|
|
8
|
+
* import { Env } from '@c9up/ream'
|
|
9
|
+
* export default await Env.create(new URL('../', import.meta.url), {
|
|
10
|
+
* HOST: Env.schema.string({ format: 'host' }),
|
|
11
|
+
* PORT: Env.schema.number(),
|
|
12
|
+
* NODE_ENV: Env.schema.enum(['development', 'production', 'test'] as const),
|
|
13
|
+
* DATABASE_URL: Env.schema.string.optional(),
|
|
14
|
+
* })
|
|
15
|
+
*
|
|
16
|
+
* // config/database.ts
|
|
17
|
+
* import env from '#start/env'
|
|
18
|
+
* const port = env.get('PORT') // typed `number`
|
|
19
|
+
*
|
|
20
|
+
* `create` loads the `.env*` files (so config read at import sees them, in EVERY
|
|
21
|
+
* flow incl. tests), validates each variable against the schema, and refuses to
|
|
22
|
+
* start with a descriptive error if a required variable is missing or invalid.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** The validated output type of a single schema node. */
|
|
26
|
+
type EnvOutput<N> = N extends SchemaNode<infer T> ? T : never
|
|
27
|
+
|
|
28
|
+
/** Map a schema object to its `{ key: validatedType }` record. */
|
|
29
|
+
type EnvRecord<S extends Record<string, SchemaNode<unknown>>> = {
|
|
30
|
+
[K in keyof S]: EnvOutput<S[K]>
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Raised when one or more environment variables fail validation. */
|
|
34
|
+
export class EnvValidationException extends Error {
|
|
35
|
+
/** Human-readable, newline-joined list of every failure (cf. Adonis `error.help`). */
|
|
36
|
+
readonly help: string
|
|
37
|
+
constructor(failures: string[]) {
|
|
38
|
+
super('Environment variables validation failed')
|
|
39
|
+
this.name = 'E_INVALID_ENV_VARIABLES'
|
|
40
|
+
this.help = failures.join('\n')
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export class Env<Values extends Record<string, unknown>> {
|
|
45
|
+
/** The validation schema surface — `Env.schema.string()`, `.number()`, … */
|
|
46
|
+
static readonly schema = schema
|
|
47
|
+
|
|
48
|
+
readonly #values: Values
|
|
49
|
+
|
|
50
|
+
private constructor(values: Values) {
|
|
51
|
+
this.#values = values
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Load `.env*` files, validate `process.env` against `schema`, and return a
|
|
56
|
+
* typed `Env`. Throws {@link EnvValidationException} (aggregating every
|
|
57
|
+
* failure) if validation fails.
|
|
58
|
+
*/
|
|
59
|
+
static async create<S extends Record<string, SchemaNode<unknown>>>(
|
|
60
|
+
appRoot: URL,
|
|
61
|
+
schema: S,
|
|
62
|
+
): Promise<Env<EnvRecord<S>>> {
|
|
63
|
+
loadEnvFiles(appRoot, { skipEnvLocal: process.env.NODE_ENV === 'test' })
|
|
64
|
+
|
|
65
|
+
const failures: string[] = []
|
|
66
|
+
const collected: Record<string, unknown> = {}
|
|
67
|
+
for (const key of Object.keys(schema)) {
|
|
68
|
+
try {
|
|
69
|
+
collected[key] = schema[key].validate(key, process.env[key])
|
|
70
|
+
} catch (err) {
|
|
71
|
+
failures.push(err instanceof Error ? err.message : String(err))
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (failures.length > 0) throw new EnvValidationException(failures)
|
|
75
|
+
|
|
76
|
+
// The ONE isolated type-erasure boundary (the same place zod / @adonisjs/env
|
|
77
|
+
// cast internally): every entry of `collected` was produced by its schema
|
|
78
|
+
// node's validator, so the object structurally IS `EnvRecord<S>` — TS just
|
|
79
|
+
// can't carry that proof through the dynamic `Object.keys` loop. Load-bearing.
|
|
80
|
+
return new Env(collected as EnvRecord<S>)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Read a validated variable. Required keys are always present; optional keys may be `undefined`. */
|
|
84
|
+
get<K extends keyof Values>(key: K): Values[K]
|
|
85
|
+
get<K extends keyof Values>(key: K, fallback: Values[K]): Values[K]
|
|
86
|
+
get<K extends keyof Values>(key: K, fallback?: Values[K]): Values[K] {
|
|
87
|
+
const value = this.#values[key]
|
|
88
|
+
if (value === undefined && fallback !== undefined) return fallback
|
|
89
|
+
return value
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import { parseEnv } from 'node:util'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Load `.env` files into `process.env` — the shared primitive behind both the
|
|
7
|
+
* Ignitor (HTTP/console boot) and `Env.create()` (config/test flow), mirroring
|
|
8
|
+
* AdonisJS which loads env in every flow.
|
|
9
|
+
*
|
|
10
|
+
* Order is most-specific-first and "already-present wins", so the shell / CI
|
|
11
|
+
* always overrides the files. Missing files are skipped silently. Uses Node's
|
|
12
|
+
* built-in parser — no dependency.
|
|
13
|
+
*
|
|
14
|
+
* @param appRoot Directory URL the `.env*` files are resolved against.
|
|
15
|
+
* @param options.skipEnvLocal Skip `.env.local` (the test flow does, so a
|
|
16
|
+
* developer's local overrides don't leak into tests).
|
|
17
|
+
*/
|
|
18
|
+
export function loadEnvFiles(appRoot: URL, options: { skipEnvLocal?: boolean } = {}): void {
|
|
19
|
+
const nodeEnv = process.env.NODE_ENV
|
|
20
|
+
const files = [
|
|
21
|
+
nodeEnv ? `.env.${nodeEnv}.local` : null,
|
|
22
|
+
options.skipEnvLocal ? null : '.env.local',
|
|
23
|
+
nodeEnv ? `.env.${nodeEnv}` : null,
|
|
24
|
+
'.env',
|
|
25
|
+
].filter((name): name is string => name !== null)
|
|
26
|
+
|
|
27
|
+
for (const name of files) {
|
|
28
|
+
let contents: string
|
|
29
|
+
try {
|
|
30
|
+
contents = readFileSync(fileURLToPath(new URL(name, appRoot)), 'utf8')
|
|
31
|
+
} catch {
|
|
32
|
+
continue // file absent — nothing to load
|
|
33
|
+
}
|
|
34
|
+
for (const [key, value] of Object.entries(parseEnv(contents))) {
|
|
35
|
+
if (typeof value === 'string' && process.env[key] === undefined) {
|
|
36
|
+
process.env[key] = value
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Env validation schema — mirrors `@adonisjs/core/env`'s `Env.schema` surface:
|
|
3
|
+
*
|
|
4
|
+
* Env.schema.string({ format: 'host' })
|
|
5
|
+
* Env.schema.number()
|
|
6
|
+
* Env.schema.boolean()
|
|
7
|
+
* Env.schema.enum(['development', 'production', 'test'] as const)
|
|
8
|
+
* Env.schema.string.optional() // shortcut form
|
|
9
|
+
* Env.schema.enum([...]).optional() // node form
|
|
10
|
+
*
|
|
11
|
+
* Each node validates + coerces a raw `process.env` string into its typed
|
|
12
|
+
* value (or throws {@link EnvVarError}); `.optional()` accepts an absent/empty
|
|
13
|
+
* value as `undefined`.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** A single failed-variable validation — collected by `Env.create`. */
|
|
17
|
+
export class EnvVarError extends Error {
|
|
18
|
+
constructor(message: string) {
|
|
19
|
+
super(message)
|
|
20
|
+
this.name = 'EnvVarError'
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** A validator: parse + coerce a raw env value (or `undefined`) into `T`. */
|
|
25
|
+
export interface SchemaNode<T> {
|
|
26
|
+
validate(name: string, value: string | undefined): T
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** A required node that can be turned optional — `.optional()` widens `T` to `T | undefined`. */
|
|
30
|
+
export interface RequiredNode<T> extends SchemaNode<T> {
|
|
31
|
+
optional(): SchemaNode<T | undefined>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface StringOptions {
|
|
35
|
+
/** Validate the string against a known format. */
|
|
36
|
+
format?: 'host' | 'url' | 'email'
|
|
37
|
+
/** For `format: 'url'` — require a TLD (default true). */
|
|
38
|
+
tld?: boolean
|
|
39
|
+
/** For `format: 'url'` — require a protocol (default true). */
|
|
40
|
+
protocol?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const TRUE_VALUES = new Set(['true', '1'])
|
|
44
|
+
const FALSE_VALUES = new Set(['false', '0'])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Build a node from a `parse` that only ever sees a present, non-empty string.
|
|
48
|
+
* Required `validate` throws on absent/empty; `.optional()` returns `undefined`.
|
|
49
|
+
*/
|
|
50
|
+
function makeNode<T>(parse: (name: string, raw: string) => T): RequiredNode<T> {
|
|
51
|
+
return {
|
|
52
|
+
validate(name, value) {
|
|
53
|
+
if (value === undefined || value === '') {
|
|
54
|
+
throw new EnvVarError(`Missing required environment variable "${name}"`)
|
|
55
|
+
}
|
|
56
|
+
return parse(name, value)
|
|
57
|
+
},
|
|
58
|
+
optional() {
|
|
59
|
+
return {
|
|
60
|
+
validate(name, value) {
|
|
61
|
+
if (value === undefined || value === '') return undefined
|
|
62
|
+
return parse(name, value)
|
|
63
|
+
},
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function isHost(value: string): boolean {
|
|
70
|
+
if (value === 'localhost') return true
|
|
71
|
+
if (/^(\d{1,3}\.){3}\d{1,3}$/.test(value)) {
|
|
72
|
+
return value.split('.').every((octet) => Number(octet) <= 255)
|
|
73
|
+
}
|
|
74
|
+
if (value.includes(':') && /^[0-9a-fA-F:]+$/.test(value)) return true // loose IPv6
|
|
75
|
+
return /^(?!-)[A-Za-z0-9-]{1,63}(?<!-)(\.(?!-)[A-Za-z0-9-]{1,63}(?<!-))*$/.test(value)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function isUrl(value: string, options: StringOptions): boolean {
|
|
79
|
+
const candidate = options.protocol === false && !value.includes('://') ? `http://${value}` : value
|
|
80
|
+
let url: URL
|
|
81
|
+
try {
|
|
82
|
+
url = new URL(candidate)
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
if (options.tld !== false && !url.hostname.includes('.') && url.hostname !== 'localhost') {
|
|
87
|
+
return false
|
|
88
|
+
}
|
|
89
|
+
return true
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function isEmail(value: string): boolean {
|
|
93
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function checkFormat(name: string, value: string, options?: StringOptions): void {
|
|
97
|
+
if (!options?.format) return
|
|
98
|
+
const ok =
|
|
99
|
+
options.format === 'host'
|
|
100
|
+
? isHost(value)
|
|
101
|
+
: options.format === 'url'
|
|
102
|
+
? isUrl(value, options)
|
|
103
|
+
: isEmail(value)
|
|
104
|
+
if (!ok) {
|
|
105
|
+
throw new EnvVarError(
|
|
106
|
+
`Environment variable "${name}" must be a valid ${options.format}, got "${value}"`,
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function stringParse(options?: StringOptions): (name: string, raw: string) => string {
|
|
112
|
+
return (name, raw) => {
|
|
113
|
+
checkFormat(name, raw, options)
|
|
114
|
+
return raw
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** `string(opts?)` is callable AND exposes `.optional(opts?)` — both Adonis forms. */
|
|
119
|
+
const string: ((options?: StringOptions) => RequiredNode<string>) & {
|
|
120
|
+
optional(options?: StringOptions): SchemaNode<string | undefined>
|
|
121
|
+
} = Object.assign((options?: StringOptions) => makeNode(stringParse(options)), {
|
|
122
|
+
optional: (options?: StringOptions) => makeNode(stringParse(options)).optional(),
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
function number(): RequiredNode<number> {
|
|
126
|
+
return makeNode((name, raw) => {
|
|
127
|
+
const parsed = Number(raw)
|
|
128
|
+
if (Number.isNaN(parsed)) {
|
|
129
|
+
throw new EnvVarError(`Environment variable "${name}" must be a number, got "${raw}"`)
|
|
130
|
+
}
|
|
131
|
+
return parsed
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function boolean(): RequiredNode<boolean> {
|
|
136
|
+
return makeNode((name, raw) => {
|
|
137
|
+
if (TRUE_VALUES.has(raw)) return true
|
|
138
|
+
if (FALSE_VALUES.has(raw)) return false
|
|
139
|
+
throw new EnvVarError(
|
|
140
|
+
`Environment variable "${name}" must be a boolean (true/false/1/0), got "${raw}"`,
|
|
141
|
+
)
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function enumNode<V extends readonly string[]>(values: V): RequiredNode<V[number]> {
|
|
146
|
+
return makeNode((name, raw) => {
|
|
147
|
+
const match = values.find((candidate) => candidate === raw)
|
|
148
|
+
if (match === undefined) {
|
|
149
|
+
throw new EnvVarError(
|
|
150
|
+
`Environment variable "${name}" must be one of [${values.join(', ')}], got "${raw}"`,
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
return match
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The `Env.schema` surface. */
|
|
158
|
+
export const schema = {
|
|
159
|
+
string,
|
|
160
|
+
number,
|
|
161
|
+
boolean,
|
|
162
|
+
enum: enumNode,
|
|
163
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -12,6 +12,14 @@ export { Application } from './Application.js'
|
|
|
12
12
|
export { ConfigStore, defineModuleConfig, env } from './ConfigLoader.js'
|
|
13
13
|
export { Container } from './container/Container.js'
|
|
14
14
|
export type { Binding, ServiceFactory, ServiceScope, ServiceToken } from './container/types.js'
|
|
15
|
+
export { Env, EnvValidationException } from './env/Env.js'
|
|
16
|
+
export { loadEnvFiles } from './env/loadEnvFiles.js'
|
|
17
|
+
export {
|
|
18
|
+
EnvVarError,
|
|
19
|
+
type RequiredNode,
|
|
20
|
+
type SchemaNode,
|
|
21
|
+
type StringOptions,
|
|
22
|
+
} from './env/schema.js'
|
|
15
23
|
|
|
16
24
|
// ─── HTTP (new — AdonisJS-compatible) ───────────────────────
|
|
17
25
|
|
|
@@ -51,12 +51,26 @@ export type HttpSender = (
|
|
|
51
51
|
},
|
|
52
52
|
) => Promise<TestResponse>
|
|
53
53
|
|
|
54
|
+
/** Options for a multipart file part — mirrors Adonis/japa's `.file()` options. */
|
|
55
|
+
export interface FilePart {
|
|
56
|
+
/** Filename advertised in the part's `Content-Disposition`. Defaults to the field name. */
|
|
57
|
+
filename?: string
|
|
58
|
+
/** MIME type of the part. Defaults to `application/octet-stream`. */
|
|
59
|
+
contentType?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** One encoded multipart/form-data part — a text field or an uploaded file. */
|
|
63
|
+
type MultipartPart =
|
|
64
|
+
| { kind: 'field'; name: string; value: string }
|
|
65
|
+
| { kind: 'file'; name: string; filename: string; contentType: string; content: Buffer }
|
|
66
|
+
|
|
54
67
|
export class RequestBuilder {
|
|
55
68
|
#sender: HttpSender
|
|
56
69
|
#method: HttpMethod
|
|
57
70
|
#path: string
|
|
58
71
|
#headers: Record<string, string> = {}
|
|
59
72
|
#body: Buffer = Buffer.alloc(0)
|
|
73
|
+
#multipart: MultipartPart[] = []
|
|
60
74
|
#cookies: Record<string, string> = {}
|
|
61
75
|
#authStrategy: AuthStrategy | null
|
|
62
76
|
#pendingAuth: AuthSubject | null = null
|
|
@@ -107,6 +121,34 @@ export class RequestBuilder {
|
|
|
107
121
|
return this
|
|
108
122
|
}
|
|
109
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Add a `multipart/form-data` text field — mirrors Adonis/japa's `.field()`.
|
|
126
|
+
* Calling `.field()` or `.file()` switches the body to multipart (use
|
|
127
|
+
* `.form()` for `application/x-www-form-urlencoded`). The boundary +
|
|
128
|
+
* `Content-Type` header are produced at send time.
|
|
129
|
+
*/
|
|
130
|
+
field(name: string, value: string): this {
|
|
131
|
+
this.#multipart.push({ kind: 'field', name, value })
|
|
132
|
+
return this
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Attach a file as a `multipart/form-data` part — mirrors Adonis/japa's
|
|
137
|
+
* `.file(field, contents, { filename, contentType })`. `contents` is a
|
|
138
|
+
* Buffer (binary) or a string (encoded as utf8). Server-side
|
|
139
|
+
* `request.file(field, …)` reads the resulting part.
|
|
140
|
+
*/
|
|
141
|
+
file(field: string, contents: Buffer | string, options: FilePart = {}): this {
|
|
142
|
+
this.#multipart.push({
|
|
143
|
+
kind: 'file',
|
|
144
|
+
name: field,
|
|
145
|
+
filename: options.filename ?? field,
|
|
146
|
+
contentType: options.contentType ?? 'application/octet-stream',
|
|
147
|
+
content: typeof contents === 'string' ? Buffer.from(contents, 'utf8') : contents,
|
|
148
|
+
})
|
|
149
|
+
return this
|
|
150
|
+
}
|
|
151
|
+
|
|
110
152
|
cookies(map: Record<string, string>): this {
|
|
111
153
|
Object.assign(this.#cookies, map)
|
|
112
154
|
return this
|
|
@@ -231,6 +273,14 @@ export class RequestBuilder {
|
|
|
231
273
|
}
|
|
232
274
|
}
|
|
233
275
|
|
|
276
|
+
// Multipart parts (.field()/.file()) take precedence over any body set by
|
|
277
|
+
// json()/form()/body(); encode them with a fresh boundary at send time.
|
|
278
|
+
if (this.#multipart.length > 0) {
|
|
279
|
+
const boundary = `----ReamRequestBuilder${crypto.randomUUID().replace(/-/g, '')}`
|
|
280
|
+
this.#body = encodeMultipart(this.#multipart, boundary)
|
|
281
|
+
this.#headers['content-type'] = `multipart/form-data; boundary=${boundary}`
|
|
282
|
+
}
|
|
283
|
+
|
|
234
284
|
// Serialise cookies into a single `Cookie:` header.
|
|
235
285
|
const cookieEntries = Object.entries(this.#cookies)
|
|
236
286
|
if (cookieEntries.length > 0) {
|
|
@@ -248,6 +298,42 @@ function capBody(s: string, max = 512): string {
|
|
|
248
298
|
return s.length <= max ? s : `${s.slice(0, max)}...[truncated]`
|
|
249
299
|
}
|
|
250
300
|
|
|
301
|
+
/** Strip CR/LF and escape quotes so a name/filename can't break the headers. */
|
|
302
|
+
function sanitizeHeaderParam(value: string): string {
|
|
303
|
+
return value.replace(/[\r\n]/g, '').replace(/"/g, '%22')
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Encode multipart/form-data parts (RFC 7578) into a single body Buffer. */
|
|
307
|
+
function encodeMultipart(parts: MultipartPart[], boundary: string): Buffer {
|
|
308
|
+
const CRLF = '\r\n'
|
|
309
|
+
const chunks: Buffer[] = []
|
|
310
|
+
for (const part of parts) {
|
|
311
|
+
if (part.kind === 'field') {
|
|
312
|
+
chunks.push(
|
|
313
|
+
Buffer.from(
|
|
314
|
+
`--${boundary}${CRLF}` +
|
|
315
|
+
`Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"${CRLF}${CRLF}` +
|
|
316
|
+
`${part.value}${CRLF}`,
|
|
317
|
+
'utf8',
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
} else {
|
|
321
|
+
chunks.push(
|
|
322
|
+
Buffer.from(
|
|
323
|
+
`--${boundary}${CRLF}` +
|
|
324
|
+
`Content-Disposition: form-data; name="${sanitizeHeaderParam(part.name)}"; filename="${sanitizeHeaderParam(part.filename)}"${CRLF}` +
|
|
325
|
+
`Content-Type: ${part.contentType}${CRLF}${CRLF}`,
|
|
326
|
+
'utf8',
|
|
327
|
+
),
|
|
328
|
+
part.content,
|
|
329
|
+
Buffer.from(CRLF, 'utf8'),
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
chunks.push(Buffer.from(`--${boundary}--${CRLF}`, 'utf8'))
|
|
334
|
+
return Buffer.concat(chunks)
|
|
335
|
+
}
|
|
336
|
+
|
|
251
337
|
/** Narrowing guard — a non-null, non-array object usable as a string map. */
|
|
252
338
|
function isRecord(x: unknown): x is Record<string, unknown> {
|
|
253
339
|
return typeof x === 'object' && x !== null && !Array.isArray(x)
|