@tnldotdev/tnl 0.1.0-rc.13 → 0.1.0-rc.17

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.
@@ -0,0 +1,19 @@
1
+ interface TnlServiceMetadata {
2
+ readonly memberNamespace: string;
3
+ readonly hostname: string;
4
+ readonly url: `https://${string}`;
5
+ }
6
+ interface BroadTnlProject {
7
+ readonly memberNamespace: string;
8
+ readonly services: Readonly<Record<string, TnlServiceMetadata>>;
9
+ }
10
+ /** Augmented by the `.tnl/project.d.ts` file generated by the tnl client. */
11
+ export interface TnlProjectMetadata {
12
+ }
13
+ type RegisteredTnlProject = keyof TnlProjectMetadata extends never ? BroadTnlProject : Readonly<TnlProjectMetadata>;
14
+ type TnlProject = RegisteredTnlProject & {
15
+ readonly runningUnderTnlDev: boolean;
16
+ };
17
+ /** Validated project metadata injected by a tnl framework integration during development. */
18
+ export declare const tnl: TnlProject | undefined;
19
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ import { parseRuntimePayload } from "./internal/runtime.js";
2
+ const serializedRuntime = typeof process === "undefined" ? undefined : process.env?.TNL_PROJECT_RUNTIME;
3
+ /** Validated project metadata injected by a tnl framework integration during development. */
4
+ export const tnl = parseRuntimePayload(serializedRuntime);
@@ -0,0 +1,46 @@
1
+ import { type ProjectMetadata, type ProjectRuntime } from "./runtime.js";
2
+ export type TnlDevEnvironment = Readonly<Record<string, string | undefined>>;
3
+ export interface TnlDevBootstrap {
4
+ readonly port?: number;
5
+ readonly socket: string;
6
+ }
7
+ export interface ProjectDocumentService {
8
+ readonly memberNamespace: string;
9
+ readonly hostname: string;
10
+ readonly url: `https://${string}`;
11
+ }
12
+ export interface ProjectDocument {
13
+ readonly memberNamespace: string;
14
+ readonly projectRoot: string;
15
+ readonly runningUnderTnlDev: boolean;
16
+ readonly serviceDirectories: Readonly<Record<string, string>>;
17
+ readonly services: Readonly<Record<string, ProjectDocumentService>>;
18
+ readonly version: 1;
19
+ }
20
+ export interface ProjectDiscovery {
21
+ readonly document: ProjectDocument;
22
+ readonly file: string;
23
+ readonly project: ProjectMetadata;
24
+ readonly service: string | null;
25
+ }
26
+ export interface DevelopmentContext {
27
+ readonly bootstrap: TnlDevBootstrap | null;
28
+ readonly localProject: ProjectMetadata | null;
29
+ }
30
+ export interface TnlTunnelAssignment extends TnlDevBootstrap {
31
+ readonly framework: string;
32
+ readonly hostname: string;
33
+ readonly memberNamespace: string;
34
+ readonly project: ProjectRuntime;
35
+ readonly publicURL: `https://${string}`;
36
+ readonly service: string | null;
37
+ readonly tunnelID: `tunnel_${string}`;
38
+ }
39
+ export type CanonicalLoopbackTarget = `http://${string}`;
40
+ export declare function readDevelopmentContext(environment?: TnlDevEnvironment, cwd?: string): DevelopmentContext;
41
+ export declare function requestTunnelAssignment(framework: string, bootstrap: TnlDevBootstrap): Promise<TnlTunnelAssignment>;
42
+ export declare function canonicalLoopbackTarget(host: string, port: number): CanonicalLoopbackTarget;
43
+ export declare function registerLocalTarget(assignment: TnlTunnelAssignment, target: CanonicalLoopbackTarget): Promise<void>;
44
+ export declare function runtimePayload(project: ProjectMetadata, runningUnderTnlDev: boolean): string;
45
+ export declare function discoverProject(cwd: string): ProjectDiscovery | null;
46
+ export declare function socketIdentity(projectRoot: string, service: string | null): string;
@@ -0,0 +1,428 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as http from "node:http";
4
+ import { isIP } from "node:net";
5
+ import * as os from "node:os";
6
+ import * as path from "node:path";
7
+ import { exactKeys, parseProjectMetadata, parseProjectRuntime, record, requiredHostname, serializeRuntimePayload, } from "./runtime.js";
8
+ const protocolVersion = "1";
9
+ const maximumDocumentBytes = 64 * 1024;
10
+ const maximumResponseBytes = 64 * 1024;
11
+ const maximumServices = 32;
12
+ const registrationTimeoutMilliseconds = 10 * 60 * 1000;
13
+ const serviceNamePattern = /^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
14
+ export function readDevelopmentContext(environment = process.env, cwd = process.cwd()) {
15
+ const protocol = environment.TNL_DEV_PROTOCOL;
16
+ if (protocol !== undefined) {
17
+ if (protocol !== protocolVersion) {
18
+ throw new Error(`unsupported tnl dev protocol ${JSON.stringify(protocol)}; upgrade tnl and its framework integrations`);
19
+ }
20
+ return Object.freeze({
21
+ bootstrap: parseBootstrapEnvironment(environment),
22
+ localProject: null,
23
+ });
24
+ }
25
+ if (environment.TNL_DEV_SOCKET !== undefined || environment.TNL_DEV_PORT !== undefined) {
26
+ throw new Error(`TNL_DEV_PROTOCOL=${protocolVersion} is required with tnl dev bootstrap values`);
27
+ }
28
+ const discovery = discoverProject(cwd);
29
+ if (discovery === null) {
30
+ return Object.freeze({ bootstrap: null, localProject: null });
31
+ }
32
+ const socket = discoverDevSocket(discovery, environment);
33
+ return Object.freeze({
34
+ bootstrap: socket === null ? null : Object.freeze({ socket }),
35
+ localProject: discovery.project,
36
+ });
37
+ }
38
+ export async function requestTunnelAssignment(framework, bootstrap) {
39
+ if (!/^[a-z]{1,32}$/.test(framework)) {
40
+ throw new Error("tnl framework name is invalid");
41
+ }
42
+ const body = JSON.stringify({ protocol: 1, framework });
43
+ const response = await sendRequest(bootstrap, framework, "/v1/configure", body, 200);
44
+ return parseAssignment(response, bootstrap, framework);
45
+ }
46
+ export function canonicalLoopbackTarget(host, port) {
47
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
48
+ throw new Error("tnl listener port must be between 1 and 65535");
49
+ }
50
+ const hostname = host.startsWith("[") && host.endsWith("]") ? host.slice(1, -1) : host;
51
+ const lowerHostname = hostname.toLowerCase();
52
+ if (lowerHostname === "localhost" || hostname === "0.0.0.0") {
53
+ return `http://127.0.0.1:${port}`;
54
+ }
55
+ if (hostname === "::") {
56
+ return `http://[::1]:${port}`;
57
+ }
58
+ if (isIP(hostname) === 4 && hostname.startsWith("127.")) {
59
+ return `http://${hostname}:${port}`;
60
+ }
61
+ if (hostname === "::1") {
62
+ return `http://[::1]:${port}`;
63
+ }
64
+ throw new Error("tnl development target must use a loopback listener");
65
+ }
66
+ export async function registerLocalTarget(assignment, target) {
67
+ const body = JSON.stringify({ protocol: 1, framework: assignment.framework, target });
68
+ await sendRequest(assignment, assignment.framework, "/v1/target", body, 204);
69
+ }
70
+ export function runtimePayload(project, runningUnderTnlDev) {
71
+ return serializeRuntimePayload(project, runningUnderTnlDev);
72
+ }
73
+ export function discoverProject(cwd) {
74
+ let directory = absoluteNormalizedPath(cwd, "working directory");
75
+ for (;;) {
76
+ const file = path.join(directory, ".tnl", "project.json");
77
+ try {
78
+ const stat = fs.lstatSync(file);
79
+ if (!stat.isFile()) {
80
+ throw new Error(`${file} must be a regular file`);
81
+ }
82
+ const document = parseProjectDocument(readBoundedFile(file), file, directory);
83
+ const service = selectService(document, cwd);
84
+ return Object.freeze({
85
+ document,
86
+ file,
87
+ project: projectMetadata(document),
88
+ service,
89
+ });
90
+ }
91
+ catch (error) {
92
+ if (!isMissing(error)) {
93
+ throw error;
94
+ }
95
+ }
96
+ const parent = path.dirname(directory);
97
+ if (parent === directory) {
98
+ return null;
99
+ }
100
+ directory = parent;
101
+ }
102
+ }
103
+ export function socketIdentity(projectRoot, service) {
104
+ return createHash("sha256")
105
+ .update(`${projectRoot}\0${service ?? ""}`)
106
+ .digest("hex")
107
+ .slice(0, 16);
108
+ }
109
+ function parseBootstrapEnvironment(environment) {
110
+ const socket = environment.TNL_DEV_SOCKET;
111
+ if (socket === undefined || socket === "") {
112
+ throw new Error(`TNL_DEV_SOCKET is required by tnl dev protocol ${protocolVersion}`);
113
+ }
114
+ const rawPort = environment.TNL_DEV_PORT;
115
+ const port = rawPort === undefined ? undefined : parsePort(rawPort, "TNL_DEV_PORT");
116
+ return Object.freeze({ port, socket });
117
+ }
118
+ function discoverDevSocket(discovery, environment) {
119
+ const getuid = process.getuid;
120
+ if (getuid === undefined) {
121
+ return null;
122
+ }
123
+ const base = environment.XDG_RUNTIME_DIR || os.tmpdir();
124
+ const directory = path.join(base, `tnl-${getuid()}`);
125
+ const socket = path.join(directory, `dev-${socketIdentity(discovery.document.projectRoot, discovery.service)}.sock`);
126
+ let directoryStat;
127
+ let socketStat;
128
+ try {
129
+ directoryStat = fs.lstatSync(directory);
130
+ socketStat = fs.lstatSync(socket);
131
+ }
132
+ catch (error) {
133
+ if (isMissing(error)) {
134
+ return null;
135
+ }
136
+ throw error;
137
+ }
138
+ if (!directoryStat.isDirectory() ||
139
+ directoryStat.uid !== getuid() ||
140
+ (directoryStat.mode & 0o777) !== 0o700 ||
141
+ !socketStat.isSocket() ||
142
+ socketStat.uid !== getuid() ||
143
+ (socketStat.mode & 0o777) !== 0o600) {
144
+ throw new Error("tnl dev runtime directory or socket has unsafe ownership or permissions");
145
+ }
146
+ return socket;
147
+ }
148
+ function parseAssignment(value, bootstrap, framework) {
149
+ const object = record(value, "tnl dev response");
150
+ exactKeys(object, ["hostname", "memberNamespace", "project", "protocol", "publicURL", "service", "tunnelID"], "tnl dev response");
151
+ if (object.protocol !== 1) {
152
+ throw new Error("tnl dev returned an inconsistent tunnel assignment");
153
+ }
154
+ if (typeof object.tunnelID !== "string" || !/^tunnel_[a-f0-9]{32}$/.test(object.tunnelID)) {
155
+ throw new Error("tnl dev returned an invalid tunnel ID");
156
+ }
157
+ if (object.service !== null && !validServiceName(object.service)) {
158
+ throw new Error("tnl dev returned an invalid service");
159
+ }
160
+ const memberNamespace = requiredHostname(object.memberNamespace, "tnl dev returned member namespace");
161
+ const hostname = requiredHostname(object.hostname, "tnl dev returned public hostname");
162
+ if (object.publicURL !== `https://${hostname}`) {
163
+ throw new Error("tnl dev returned an invalid public URL");
164
+ }
165
+ const project = parseProjectRuntime(object.project, "tnl dev project metadata");
166
+ if (!project.runningUnderTnlDev) {
167
+ throw new Error("tnl dev returned project metadata outside tnl dev");
168
+ }
169
+ const assignmentNamespace = object.service === null
170
+ ? project.memberNamespace
171
+ : project.services[object.service]?.memberNamespace;
172
+ if (assignmentNamespace !== memberNamespace) {
173
+ throw new Error("tnl dev returned inconsistent project metadata");
174
+ }
175
+ if (object.service !== null && project.services[object.service] === undefined) {
176
+ throw new Error("tnl dev returned inconsistent project metadata");
177
+ }
178
+ if (object.service !== null && project.services[object.service]?.hostname !== hostname) {
179
+ throw new Error("tnl dev returned inconsistent project metadata");
180
+ }
181
+ return Object.freeze({
182
+ ...bootstrap,
183
+ framework,
184
+ hostname,
185
+ memberNamespace,
186
+ project,
187
+ publicURL: object.publicURL,
188
+ service: object.service,
189
+ tunnelID: object.tunnelID,
190
+ });
191
+ }
192
+ function parseProjectDocument(serialized, description, projectRoot) {
193
+ let value;
194
+ try {
195
+ value = JSON.parse(serialized);
196
+ }
197
+ catch (error) {
198
+ throw new Error(`${description} is not valid JSON`, { cause: error });
199
+ }
200
+ return parseProjectDocumentValue(value, description, projectRoot);
201
+ }
202
+ function parseProjectDocumentValue(value, description, projectRoot) {
203
+ const object = record(value, description);
204
+ exactKeys(object, ["memberNamespace", "runningUnderTnlDev", "serviceDirectories", "services", "version"], description);
205
+ if (object.version !== 1) {
206
+ throw new Error(`${description} has an unsupported version`);
207
+ }
208
+ if (typeof object.runningUnderTnlDev !== "boolean") {
209
+ throw new Error(`${description} has an invalid runningUnderTnlDev value`);
210
+ }
211
+ if (object.runningUnderTnlDev) {
212
+ throw new Error(`${description} cannot be marked as running under tnl dev`);
213
+ }
214
+ const memberNamespace = requiredHostname(object.memberNamespace, `${description} member namespace`);
215
+ const serviceValues = record(object.services, `${description} services`);
216
+ const directoryValues = record(object.serviceDirectories, `${description} service directories`);
217
+ const entries = Object.entries(serviceValues);
218
+ if (entries.length > maximumServices) {
219
+ throw new Error(`${description} may contain at most ${maximumServices} services`);
220
+ }
221
+ if (Object.keys(directoryValues).length !== entries.length ||
222
+ entries.some(([name]) => !Object.hasOwn(directoryValues, name))) {
223
+ throw new Error(`${description} requires one directory for every service`);
224
+ }
225
+ const services = {};
226
+ const serviceDirectories = {};
227
+ const hostnames = new Set();
228
+ for (const [name, value] of entries) {
229
+ if (!validServiceName(name)) {
230
+ throw new Error(`${description} contains an invalid service name ${JSON.stringify(name)}`);
231
+ }
232
+ const service = record(value, `${description} service ${JSON.stringify(name)}`);
233
+ exactKeys(service, ["hostname", "memberNamespace", "url"], `${description} service ${JSON.stringify(name)}`);
234
+ const directory = relativeDirectory(directoryValues[name], `${description} service ${JSON.stringify(name)} directory`);
235
+ const serviceMemberNamespace = requiredHostname(service.memberNamespace, `${description} service ${JSON.stringify(name)} member namespace`);
236
+ const hostname = requiredHostname(service.hostname, `${description} service ${JSON.stringify(name)} hostname`);
237
+ if (service.url !== `https://${hostname}`) {
238
+ throw new Error(`${description} service ${JSON.stringify(name)} has an invalid URL`);
239
+ }
240
+ if (hostnames.has(hostname)) {
241
+ throw new Error(`${description} contains duplicate service hostname ${hostname}`);
242
+ }
243
+ hostnames.add(hostname);
244
+ services[name] = Object.freeze({
245
+ memberNamespace: serviceMemberNamespace,
246
+ hostname,
247
+ url: service.url,
248
+ });
249
+ serviceDirectories[name] = directory;
250
+ }
251
+ return Object.freeze({
252
+ memberNamespace,
253
+ projectRoot,
254
+ runningUnderTnlDev: object.runningUnderTnlDev,
255
+ serviceDirectories: Object.freeze(serviceDirectories),
256
+ services: Object.freeze(services),
257
+ version: 1,
258
+ });
259
+ }
260
+ function projectMetadata(document) {
261
+ return parseProjectMetadata({
262
+ memberNamespace: document.memberNamespace,
263
+ services: Object.fromEntries(Object.entries(document.services).map(([name, service]) => [
264
+ name,
265
+ {
266
+ hostname: service.hostname,
267
+ memberNamespace: service.memberNamespace,
268
+ url: service.url,
269
+ },
270
+ ])),
271
+ }, "tnl project metadata");
272
+ }
273
+ function selectService(document, cwd) {
274
+ const absoluteCwd = absoluteNormalizedPath(cwd, "working directory");
275
+ const canonicalCwd = fs.realpathSync.native(absoluteCwd);
276
+ const canonicalRoot = fs.realpathSync.native(document.projectRoot);
277
+ if (!pathWithin(canonicalCwd, canonicalRoot)) {
278
+ throw new Error("working directory is outside the discovered tnl project");
279
+ }
280
+ const matches = Object.entries(document.serviceDirectories)
281
+ .map(([name, directory]) => [name, canonicalPath(path.join(document.projectRoot, directory))])
282
+ .filter(([, directory]) => pathWithin(canonicalCwd, directory))
283
+ .sort((left, right) => right[1].length - left[1].length);
284
+ if (matches.length > 1 && matches[0]?.[1].length === matches[1]?.[1].length) {
285
+ throw new Error("working directory matches multiple tnl service directories");
286
+ }
287
+ return matches[0]?.[0] ?? null;
288
+ }
289
+ function relativeDirectory(value, description) {
290
+ if (typeof value !== "string" || value === "" || value.includes("\0") || path.isAbsolute(value)) {
291
+ throw new Error(`${description} must be a non-empty relative path`);
292
+ }
293
+ const normalized = path.normalize(value);
294
+ if (normalized === ".." ||
295
+ normalized.startsWith(`..${path.sep}`) ||
296
+ normalized.split(path.sep).join("/") !== value) {
297
+ throw new Error(`${description} must be a clean relative path`);
298
+ }
299
+ return value;
300
+ }
301
+ function sendRequest(bootstrap, framework, requestPath, body, expectedStatus) {
302
+ return new Promise((resolve, reject) => {
303
+ const request = http.request({
304
+ socketPath: bootstrap.socket,
305
+ path: requestPath,
306
+ method: "POST",
307
+ headers: {
308
+ "Content-Length": Buffer.byteLength(body),
309
+ "Content-Type": "application/json",
310
+ },
311
+ }, (response) => {
312
+ response.on("error", reject);
313
+ const declaredLength = Number(response.headers["content-length"]);
314
+ if (Number.isFinite(declaredLength) && declaredLength > maximumResponseBytes) {
315
+ response.destroy(new Error("tnl dev returned an oversized response"));
316
+ return;
317
+ }
318
+ const chunks = [];
319
+ let size = 0;
320
+ response.on("data", (chunk) => {
321
+ size += chunk.length;
322
+ if (size > maximumResponseBytes) {
323
+ response.destroy(new Error("tnl dev returned an oversized response"));
324
+ return;
325
+ }
326
+ chunks.push(chunk);
327
+ });
328
+ response.on("end", () => {
329
+ const data = Buffer.concat(chunks).toString("utf8").trim();
330
+ if (response.statusCode !== expectedStatus) {
331
+ reject(new Error(`tnl dev rejected the ${framework} request with status ${response.statusCode}${data ? `: ${data}` : ""}`));
332
+ return;
333
+ }
334
+ if (expectedStatus === 204) {
335
+ if (data !== "") {
336
+ reject(new Error("tnl dev returned an unexpected target response"));
337
+ return;
338
+ }
339
+ resolve(undefined);
340
+ return;
341
+ }
342
+ if (response.headers["content-type"]?.split(";", 1)[0]?.trim() !== "application/json") {
343
+ reject(new Error("tnl dev returned a non-JSON configuration"));
344
+ return;
345
+ }
346
+ try {
347
+ resolve(JSON.parse(data));
348
+ }
349
+ catch (error) {
350
+ reject(new Error("tnl dev returned an invalid configuration", { cause: error }));
351
+ }
352
+ });
353
+ });
354
+ request.setTimeout(registrationTimeoutMilliseconds, () => {
355
+ request.destroy(new Error("timed out configuring the target with tnl dev"));
356
+ });
357
+ request.on("error", reject);
358
+ request.end(body);
359
+ });
360
+ }
361
+ function readBoundedFile(file) {
362
+ const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0);
363
+ const descriptor = fs.openSync(file, flags);
364
+ try {
365
+ const stat = fs.fstatSync(descriptor);
366
+ if (!stat.isFile()) {
367
+ throw new Error(`${file} must be a regular file`);
368
+ }
369
+ const buffer = Buffer.allocUnsafe(maximumDocumentBytes + 1);
370
+ let length = 0;
371
+ for (;;) {
372
+ const read = fs.readSync(descriptor, buffer, length, buffer.length - length, null);
373
+ length += read;
374
+ if (read === 0 || length === buffer.length) {
375
+ break;
376
+ }
377
+ }
378
+ if (length > maximumDocumentBytes) {
379
+ throw new Error(`${file} exceeds ${maximumDocumentBytes} bytes`);
380
+ }
381
+ try {
382
+ return new TextDecoder("utf-8", { fatal: true }).decode(buffer.subarray(0, length));
383
+ }
384
+ catch (error) {
385
+ throw new Error(`${file} is not valid UTF-8`, { cause: error });
386
+ }
387
+ }
388
+ finally {
389
+ fs.closeSync(descriptor);
390
+ }
391
+ }
392
+ function absoluteNormalizedPath(value, description) {
393
+ if (typeof value !== "string" ||
394
+ value === "" ||
395
+ !path.isAbsolute(value) ||
396
+ path.normalize(value) !== value) {
397
+ throw new Error(`${description} must be an absolute normalized path`);
398
+ }
399
+ return value;
400
+ }
401
+ function parsePort(value, source) {
402
+ if (!/^[0-9]+$/.test(value)) {
403
+ throw new Error(`${source} must be a port between 1 and 65535`);
404
+ }
405
+ const port = Number(value);
406
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
407
+ throw new Error(`${source} must be a port between 1 and 65535`);
408
+ }
409
+ return port;
410
+ }
411
+ function validServiceName(value) {
412
+ return typeof value === "string" && serviceNamePattern.test(value);
413
+ }
414
+ function pathWithin(value, root) {
415
+ const relative = path.relative(root, value);
416
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`));
417
+ }
418
+ function canonicalPath(value) {
419
+ try {
420
+ return fs.realpathSync.native(value);
421
+ }
422
+ catch {
423
+ return value;
424
+ }
425
+ }
426
+ function isMissing(error) {
427
+ return error.code === "ENOENT";
428
+ }
@@ -0,0 +1,19 @@
1
+ export interface ProjectServiceMetadata {
2
+ readonly memberNamespace: string;
3
+ readonly hostname: string;
4
+ readonly url: `https://${string}`;
5
+ }
6
+ export interface ProjectMetadata {
7
+ readonly memberNamespace: string;
8
+ readonly services: Readonly<Record<string, ProjectServiceMetadata>>;
9
+ }
10
+ export interface ProjectRuntime extends ProjectMetadata {
11
+ readonly runningUnderTnlDev: boolean;
12
+ }
13
+ export declare function parseProjectMetadata(value: unknown, description: string): ProjectMetadata;
14
+ export declare function parseRuntimePayload(serialized: string | undefined): ProjectRuntime | undefined;
15
+ export declare function parseProjectRuntime(value: unknown, description: string): ProjectRuntime;
16
+ export declare function serializeRuntimePayload(project: ProjectMetadata, runningUnderTnlDev: boolean): string;
17
+ export declare function requiredHostname(value: unknown, description: string): string;
18
+ export declare function exactKeys(object: Record<string, unknown>, expected: readonly string[], description: string): void;
19
+ export declare function record(value: unknown, description: string): Record<string, unknown>;
@@ -0,0 +1,113 @@
1
+ const maximumRuntimeBytes = 64 * 1024;
2
+ const maximumServices = 32;
3
+ const serviceNamePattern = /^[a-z](?:[a-z0-9-]{0,30}[a-z0-9])?$/;
4
+ export function parseProjectMetadata(value, description) {
5
+ const object = record(value, description);
6
+ exactKeys(object, ["memberNamespace", "services"], description);
7
+ const memberNamespace = requiredHostname(object.memberNamespace, `${description} member namespace`);
8
+ const servicesObject = record(object.services, `${description} services`);
9
+ const entries = Object.entries(servicesObject);
10
+ if (entries.length > maximumServices) {
11
+ throw new Error(`${description} may contain at most ${maximumServices} services`);
12
+ }
13
+ const services = {};
14
+ const hostnames = new Set();
15
+ for (const [name, value] of entries) {
16
+ if (!serviceNamePattern.test(name)) {
17
+ throw new Error(`${description} contains an invalid service name ${JSON.stringify(name)}`);
18
+ }
19
+ const service = record(value, `${description} service ${JSON.stringify(name)}`);
20
+ exactKeys(service, ["hostname", "memberNamespace", "url"], `${description} service ${JSON.stringify(name)}`);
21
+ const serviceMemberNamespace = requiredHostname(service.memberNamespace, `${description} service ${JSON.stringify(name)} member namespace`);
22
+ const hostname = requiredHostname(service.hostname, `${description} service ${JSON.stringify(name)} hostname`);
23
+ if (service.url !== `https://${hostname}`) {
24
+ throw new Error(`${description} service ${JSON.stringify(name)} has an invalid URL`);
25
+ }
26
+ if (hostnames.has(hostname)) {
27
+ throw new Error(`${description} contains duplicate service hostname ${hostname}`);
28
+ }
29
+ hostnames.add(hostname);
30
+ services[name] = Object.freeze({
31
+ memberNamespace: serviceMemberNamespace,
32
+ hostname,
33
+ url: service.url,
34
+ });
35
+ }
36
+ return Object.freeze({
37
+ memberNamespace,
38
+ services: Object.freeze(services),
39
+ });
40
+ }
41
+ export function parseRuntimePayload(serialized) {
42
+ if (serialized === undefined) {
43
+ return undefined;
44
+ }
45
+ if (byteLength(serialized) > maximumRuntimeBytes) {
46
+ throw new Error(`tnl runtime payload exceeds ${maximumRuntimeBytes} bytes`);
47
+ }
48
+ let value;
49
+ try {
50
+ value = JSON.parse(serialized);
51
+ }
52
+ catch (error) {
53
+ throw new Error("tnl runtime payload is not valid JSON", { cause: error });
54
+ }
55
+ return parseProjectRuntime(value, "tnl runtime payload");
56
+ }
57
+ export function parseProjectRuntime(value, description) {
58
+ const object = record(value, description);
59
+ exactKeys(object, ["memberNamespace", "runningUnderTnlDev", "services"], description);
60
+ if (typeof object.runningUnderTnlDev !== "boolean") {
61
+ throw new Error(`${description} has an invalid runningUnderTnlDev value`);
62
+ }
63
+ const project = parseProjectMetadata({ memberNamespace: object.memberNamespace, services: object.services }, description);
64
+ return Object.freeze({
65
+ ...project,
66
+ runningUnderTnlDev: object.runningUnderTnlDev,
67
+ });
68
+ }
69
+ export function serializeRuntimePayload(project, runningUnderTnlDev) {
70
+ const serialized = JSON.stringify({
71
+ memberNamespace: project.memberNamespace,
72
+ runningUnderTnlDev,
73
+ services: project.services,
74
+ });
75
+ if (byteLength(serialized) > maximumRuntimeBytes) {
76
+ throw new Error(`tnl runtime payload exceeds ${maximumRuntimeBytes} bytes`);
77
+ }
78
+ return serialized;
79
+ }
80
+ export function requiredHostname(value, description) {
81
+ if (typeof value !== "string" || value.length === 0 || value.length > 253) {
82
+ throw new Error(`${description} is invalid`);
83
+ }
84
+ if (value !== value.toLowerCase() || value.endsWith(".")) {
85
+ throw new Error(`${description} is invalid`);
86
+ }
87
+ const labels = value.split(".");
88
+ if (labels.some((label) => label.length === 0 || label.length > 63 || !/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(label))) {
89
+ throw new Error(`${description} is invalid`);
90
+ }
91
+ if (labels.length === 4 &&
92
+ labels.every((label) => /^[0-9]+$/.test(label) && Number(label) <= 255)) {
93
+ throw new Error(`${description} is invalid`);
94
+ }
95
+ return value;
96
+ }
97
+ export function exactKeys(object, expected, description) {
98
+ const keys = Object.keys(object).sort();
99
+ const sortedExpected = [...expected].sort();
100
+ if (keys.length !== sortedExpected.length ||
101
+ keys.some((key, index) => key !== sortedExpected[index])) {
102
+ throw new Error(`${description} has an invalid shape`);
103
+ }
104
+ }
105
+ export function record(value, description) {
106
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
107
+ throw new Error(`${description} must be an object`);
108
+ }
109
+ return value;
110
+ }
111
+ function byteLength(value) {
112
+ return new TextEncoder().encode(value).byteLength;
113
+ }
package/dist/next.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { NextConfig } from "next";
2
+ export interface NextConfigContext {
3
+ /** The default Next.js configuration. */
4
+ readonly defaultConfig: NextConfig;
5
+ }
6
+ /** A function that returns Next.js configuration. */
7
+ export type NextConfigFactory = (phase: string, context: NextConfigContext) => NextConfig | Promise<NextConfig>;
8
+ export type NextConfigInput = NextConfig | Promise<NextConfig> | NextConfigFactory;
9
+ /** Adds tnl project metadata and safe `tnl dev` routing to a Next.js development server. */
10
+ export declare function withTnl(config?: NextConfigInput, ...extra: never[]): NextConfigFactory;