@tnldotdev/tnl 0.1.0-rc.15 → 0.1.0-rc.20

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