orionfold-relay 0.15.2 → 0.15.4
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/cli.js
CHANGED
|
@@ -9347,6 +9347,26 @@ var init_helpers2 = __esm({
|
|
|
9347
9347
|
// src/lib/chat/tools/project-tools.ts
|
|
9348
9348
|
import { z as z9 } from "zod";
|
|
9349
9349
|
import { eq as eq21, and as and10, count } from "drizzle-orm";
|
|
9350
|
+
async function findSimilarProjects(candidateName) {
|
|
9351
|
+
const candidateNameLower = candidateName.trim().toLowerCase();
|
|
9352
|
+
if (!candidateNameLower) return [];
|
|
9353
|
+
const existing = await db.select({
|
|
9354
|
+
id: projects.id,
|
|
9355
|
+
name: projects.name,
|
|
9356
|
+
description: projects.description
|
|
9357
|
+
}).from(projects);
|
|
9358
|
+
const matches = [];
|
|
9359
|
+
for (const row of existing) {
|
|
9360
|
+
if (row.name.trim().toLowerCase() === candidateNameLower) {
|
|
9361
|
+
matches.push({
|
|
9362
|
+
id: row.id,
|
|
9363
|
+
name: row.name,
|
|
9364
|
+
reason: `Same name: "${row.name}"`
|
|
9365
|
+
});
|
|
9366
|
+
}
|
|
9367
|
+
}
|
|
9368
|
+
return matches;
|
|
9369
|
+
}
|
|
9350
9370
|
function projectTools(ctx) {
|
|
9351
9371
|
return [
|
|
9352
9372
|
defineTool(
|
|
@@ -9380,10 +9400,23 @@ function projectTools(ctx) {
|
|
|
9380
9400
|
{
|
|
9381
9401
|
name: z9.string().min(1).max(100).describe("Project name"),
|
|
9382
9402
|
description: z9.string().max(500).optional().describe("Project description"),
|
|
9383
|
-
workingDirectory: z9.string().max(500).optional().describe("Absolute path to the project's working directory")
|
|
9403
|
+
workingDirectory: z9.string().max(500).optional().describe("Absolute path to the project's working directory"),
|
|
9404
|
+
force: z9.boolean().optional().describe(
|
|
9405
|
+
"Set to true to create a project even when one with the same name already exists. Only use this when the user has explicitly confirmed they want a second same-named project. Default false \u2014 normally you should reuse the existing project returned by the near-duplicate check (its id) instead of creating a duplicate."
|
|
9406
|
+
)
|
|
9384
9407
|
},
|
|
9385
9408
|
async (args) => {
|
|
9386
9409
|
try {
|
|
9410
|
+
if (!args.force) {
|
|
9411
|
+
const similar = await findSimilarProjects(args.name);
|
|
9412
|
+
if (similar.length > 0) {
|
|
9413
|
+
return ok({
|
|
9414
|
+
status: "similar-found",
|
|
9415
|
+
message: "A project with this name already exists. Reuse it by its id for subsequent artifacts (profiles, tables, workflows), or pass force=true to create a separate same-named project.",
|
|
9416
|
+
matches: similar
|
|
9417
|
+
});
|
|
9418
|
+
}
|
|
9419
|
+
}
|
|
9387
9420
|
const now = /* @__PURE__ */ new Date();
|
|
9388
9421
|
const id = crypto.randomUUID();
|
|
9389
9422
|
await db.insert(projects).values({
|
|
@@ -25202,8 +25235,8 @@ import { execFileSync as execFileSync3 } from "child_process";
|
|
|
25202
25235
|
import yaml12 from "js-yaml";
|
|
25203
25236
|
import semver from "semver";
|
|
25204
25237
|
function relayCoreVersion() {
|
|
25205
|
-
if (semver.valid("0.15.
|
|
25206
|
-
return "0.15.
|
|
25238
|
+
if (semver.valid("0.15.4")) {
|
|
25239
|
+
return "0.15.4";
|
|
25207
25240
|
}
|
|
25208
25241
|
try {
|
|
25209
25242
|
const root = getAppRoot(import.meta.dirname, 3);
|
|
@@ -25645,6 +25678,12 @@ function buildNextLaunchArgs({
|
|
|
25645
25678
|
function buildSidecarUrl(port, host = SIDECAR_LOOPBACK_HOST) {
|
|
25646
25679
|
return `http://${host}:${port}`;
|
|
25647
25680
|
}
|
|
25681
|
+
function isNonLoopbackHost(host) {
|
|
25682
|
+
const h = host.trim().toLowerCase();
|
|
25683
|
+
if (h === "localhost" || h === "::1") return false;
|
|
25684
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return false;
|
|
25685
|
+
return true;
|
|
25686
|
+
}
|
|
25648
25687
|
|
|
25649
25688
|
// bin/cli.ts
|
|
25650
25689
|
init_ainative_paths();
|
|
@@ -25842,6 +25881,7 @@ Environment variables:
|
|
|
25842
25881
|
|
|
25843
25882
|
Examples:
|
|
25844
25883
|
node dist/cli.js --port 3210 --no-open
|
|
25884
|
+
node dist/cli.js --hostname 0.0.0.0 --port 3000 # expose on the LAN (see warning)
|
|
25845
25885
|
node dist/cli.js --data-dir ~/.relay-dogfood --port 3100
|
|
25846
25886
|
node dist/cli.js plugin dry-run my-plugin # print confinement policy
|
|
25847
25887
|
node dist/cli.js pack add ./my-pack # install a Relay pack (folder or git url)
|
|
@@ -25849,7 +25889,11 @@ Examples:
|
|
|
25849
25889
|
node dist/cli.js pack remove my-pack # uninstall a pack
|
|
25850
25890
|
`;
|
|
25851
25891
|
}
|
|
25852
|
-
program.name("relay").description("Orionfold Relay \u2014 a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.").version(pkg.version).addHelpText("after", getHelpText).option("-p, --port <number>", "port to start on", "3000").option(
|
|
25892
|
+
program.name("relay").description("Orionfold Relay \u2014 a local-first, multi-agent orchestration runtime and builder scaffold for AI-native work.").version(pkg.version).addHelpText("after", getHelpText).option("-p, --port <number>", "port to start on", "3000").option(
|
|
25893
|
+
"--hostname <host>",
|
|
25894
|
+
"host to bind to (default 127.0.0.1; use 0.0.0.0 to expose on the network)",
|
|
25895
|
+
"127.0.0.1"
|
|
25896
|
+
).option("--data-dir <path>", "custom data directory (overrides RELAY_DATA_DIR)").option("--reset", "delete the local database before starting").option("--no-open", "don't auto-open browser").option("--safe-mode", "disable Kind-1 plugin MCP servers; Kind-5 primitives bundles still load");
|
|
25853
25897
|
var firstArg = process.argv[2];
|
|
25854
25898
|
var isPluginSubcommand = firstArg === "plugin";
|
|
25855
25899
|
var isPackSubcommand = firstArg === "pack";
|
|
@@ -25979,11 +26023,18 @@ async function main() {
|
|
|
25979
26023
|
}
|
|
25980
26024
|
const nextEntrypoint = resolveNextEntrypoint(effectiveCwd);
|
|
25981
26025
|
const isPrebuilt = existsSync12(join20(effectiveCwd, ".next", "BUILD_ID"));
|
|
26026
|
+
const bindHost = opts.hostname || "127.0.0.1";
|
|
26027
|
+
if (isNonLoopbackHost(bindHost)) {
|
|
26028
|
+
console.warn(
|
|
26029
|
+
`\u26A0 Binding to ${bindHost} \u2014 Relay will be reachable from other machines on the network. It is designed for local-first, single-user use and has no network authentication. Only do this on a trusted network, and put a reverse proxy with auth in front if exposing it more broadly.`
|
|
26030
|
+
);
|
|
26031
|
+
}
|
|
25982
26032
|
const nextArgs = buildNextLaunchArgs({
|
|
25983
26033
|
isPrebuilt,
|
|
25984
|
-
port: actualPort
|
|
26034
|
+
port: actualPort,
|
|
26035
|
+
host: bindHost
|
|
25985
26036
|
});
|
|
25986
|
-
const sidecarUrl = buildSidecarUrl(actualPort);
|
|
26037
|
+
const sidecarUrl = buildSidecarUrl(actualPort, bindHost);
|
|
25987
26038
|
console.log(`Orionfold Relay ${pkg.version} \u2014 Community Edition`);
|
|
25988
26039
|
console.log(`Data dir: ${DATA_DIR}`);
|
|
25989
26040
|
console.log(`Mode: ${isPrebuilt ? "production" : "development"}`);
|
|
@@ -26002,10 +26053,11 @@ async function main() {
|
|
|
26002
26053
|
}
|
|
26003
26054
|
});
|
|
26004
26055
|
if (opts.open !== false) {
|
|
26056
|
+
const openUrl = isNonLoopbackHost(bindHost) ? buildSidecarUrl(actualPort, "127.0.0.1") : sidecarUrl;
|
|
26005
26057
|
setTimeout(async () => {
|
|
26006
26058
|
try {
|
|
26007
26059
|
const open = (await import("open")).default;
|
|
26008
|
-
await open(
|
|
26060
|
+
await open(openUrl);
|
|
26009
26061
|
} catch {
|
|
26010
26062
|
}
|
|
26011
26063
|
}, 3e3);
|
package/package.json
CHANGED
package/src/lib/chat/engine.ts
CHANGED
|
@@ -672,9 +672,32 @@ export async function* sendMessage(
|
|
|
672
672
|
const pendingScreenshotTools = new Set<string>(); // tool_use IDs for screenshot tools
|
|
673
673
|
const screenshotAttachments: ScreenshotAttachment[] = [];
|
|
674
674
|
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
675
|
+
// Race the SDK iterator against the permission side-channel.
|
|
676
|
+
//
|
|
677
|
+
// The Agent SDK's canUseTool callback pauses the SDK indefinitely while a
|
|
678
|
+
// permission gate is pending (see docs: "Execution remains paused until
|
|
679
|
+
// your callback returns"). During that pause the SDK emits NO events, so a
|
|
680
|
+
// plain `for await` over the iterator parks — and any UI event a *second*
|
|
681
|
+
// concurrent gate pushes onto the side-channel would sit undrained until
|
|
682
|
+
// the 120s auto-deny fired. That was the "silent second gate" deadlock:
|
|
683
|
+
// the loop's only driver (SDK events) is exactly what the pending gate
|
|
684
|
+
// stalls.
|
|
685
|
+
//
|
|
686
|
+
// Instead we drive the iterator manually and race each `.next()` against a
|
|
687
|
+
// blocking `sideChannel.pull()`. When a side-channel event wins (a gate
|
|
688
|
+
// surfacing while the SDK is paused), we yield it immediately and loop
|
|
689
|
+
// again WITHOUT re-issuing `.next()` — the outstanding SDK promise is kept
|
|
690
|
+
// in `sdkNext` and only replaced once it resolves, so we never call
|
|
691
|
+
// `.next()` twice concurrently. This mirrors the codex-engine wake-signal
|
|
692
|
+
// loop, which never had this bug.
|
|
693
|
+
const sdkIterator = (response as AsyncIterable<Record<string, unknown>>)[
|
|
694
|
+
Symbol.asyncIterator
|
|
695
|
+
]();
|
|
696
|
+
let sdkNext: Promise<IteratorResult<Record<string, unknown>>> | null =
|
|
697
|
+
sdkIterator.next();
|
|
698
|
+
let sdkDone = false;
|
|
699
|
+
|
|
700
|
+
while (!sdkDone) {
|
|
678
701
|
if (signal?.aborted) break;
|
|
679
702
|
|
|
680
703
|
// Signal that the model has connected and is processing
|
|
@@ -683,7 +706,35 @@ export async function* sendMessage(
|
|
|
683
706
|
yield { type: "status", phase: "generating", message: "Generating response..." };
|
|
684
707
|
}
|
|
685
708
|
|
|
686
|
-
//
|
|
709
|
+
// Race: whichever of the SDK event or a side-channel push resolves first.
|
|
710
|
+
// The SDK promise is persistent (never re-issued while pending); the pull
|
|
711
|
+
// is recreated each turn and resolves with `undefined` on channel close.
|
|
712
|
+
const winner = await Promise.race([
|
|
713
|
+
sdkNext.then((res) => ({ kind: "sdk" as const, res })),
|
|
714
|
+
sideChannel
|
|
715
|
+
.pull()
|
|
716
|
+
.then((event) => ({ kind: "side-channel" as const, event })),
|
|
717
|
+
]);
|
|
718
|
+
|
|
719
|
+
// Side-channel won: the SDK is (likely) paused on a gate. Surface the
|
|
720
|
+
// event now and loop again — `sdkNext` stays pending, unresolved.
|
|
721
|
+
if (winner.kind === "side-channel") {
|
|
722
|
+
// `undefined` means the channel closed (turn ending); ignore it.
|
|
723
|
+
if (winner.event) yield winner.event;
|
|
724
|
+
continue;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
// SDK event won: consume it and re-arm the iterator for the next turn.
|
|
728
|
+
const { res } = winner;
|
|
729
|
+
sdkNext = null;
|
|
730
|
+
if (res.done) {
|
|
731
|
+
sdkDone = true;
|
|
732
|
+
break;
|
|
733
|
+
}
|
|
734
|
+
const raw = res.value;
|
|
735
|
+
sdkNext = sdkIterator.next();
|
|
736
|
+
|
|
737
|
+
// Drain any side-channel events buffered alongside this SDK event.
|
|
687
738
|
for (const sideEvent of sideChannel.drain()) {
|
|
688
739
|
yield sideEvent;
|
|
689
740
|
}
|
|
@@ -40,7 +40,7 @@ const pendingRequests = new Map<string, PendingRequest>();
|
|
|
40
40
|
*/
|
|
41
41
|
export class AsyncQueue<T> {
|
|
42
42
|
private buffer: T[] = [];
|
|
43
|
-
private waiters: Array<(value: T) => void> = [];
|
|
43
|
+
private waiters: Array<(value: T | undefined) => void> = [];
|
|
44
44
|
private closed = false;
|
|
45
45
|
|
|
46
46
|
push(item: T) {
|
|
@@ -60,12 +60,35 @@ export class AsyncQueue<T> {
|
|
|
60
60
|
return items;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Block until the next item is available, then resolve with it.
|
|
65
|
+
*
|
|
66
|
+
* This is the wake-signal the Claude chat engine races against the SDK
|
|
67
|
+
* iterator: when `canUseTool` pauses the SDK (no more SDK events until a
|
|
68
|
+
* gate resolves), a pull() that began awaiting before the next
|
|
69
|
+
* `emitSideChannelEvent` still resolves the moment that event is pushed —
|
|
70
|
+
* so a second permission gate surfaces immediately instead of stalling
|
|
71
|
+
* until the 120s auto-deny. Resolves with `undefined` (not a rejection)
|
|
72
|
+
* when the queue closes, so a losing race branch never throws.
|
|
73
|
+
*/
|
|
74
|
+
pull(): Promise<T | undefined> {
|
|
75
|
+
if (this.buffer.length > 0) {
|
|
76
|
+
return Promise.resolve(this.buffer.shift());
|
|
77
|
+
}
|
|
78
|
+
if (this.closed) {
|
|
79
|
+
return Promise.resolve(undefined);
|
|
80
|
+
}
|
|
81
|
+
return new Promise<T | undefined>((resolve) => {
|
|
82
|
+
this.waiters.push(resolve);
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Close the queue — any pending waiters resolve with the `undefined` sentinel */
|
|
64
87
|
close() {
|
|
65
88
|
this.closed = true;
|
|
66
89
|
this.buffer = [];
|
|
67
90
|
for (const waiter of this.waiters) {
|
|
68
|
-
|
|
91
|
+
waiter(undefined);
|
|
69
92
|
}
|
|
70
93
|
this.waiters = [];
|
|
71
94
|
}
|
|
@@ -128,6 +128,7 @@ Be proactive with tools. If the user asks about project status, use list_tasks t
|
|
|
128
128
|
- For workflows, valid patterns are: sequence, parallel, checkpoint, planner-executor, swarm, loop.
|
|
129
129
|
- **Delay steps** (sequence pattern only): a step with \`delayDuration\` (format: Nm|Nh|Nd|Nw, bounds 1m..30d) pauses the workflow between task steps. Format examples: "30m", "2h", "3d", "1w". Delay steps must have NO profile or prompt — they are pure waits. Use them for outreach sequences, drip campaigns, cooling periods, staged rollouts. A paused workflow resumes automatically when its scheduled time arrives, or immediately when the user clicks "Resume Now".
|
|
130
130
|
- **enrich_table idempotency:** \`enrich_table\` skips rows where the target column already has a non-empty value. If the user wants to overwrite existing values, explain that force re-enrichment is not supported in v1 — they must manually clear the target column first (e.g. via update_row) before re-running.
|
|
131
|
+
- **create_project dedup / reuse:** When composing an app for a named client, reuse an existing project instead of creating a duplicate. \`create_project\` performs its own exact-name check and will return \`{status: "similar-found", matches: [...]}\` instead of inserting when a same-named project already exists — when that happens, use the returned project \`id\` for every subsequent artifact (profiles, tables, workflows, schedules) rather than retrying \`create_project\`. Only pass \`force: true\` when the user has explicitly confirmed they want a second same-named project.
|
|
131
132
|
- **create_workflow dedup:** Before calling \`create_workflow\`, call \`list_workflows\` (filtered by the current project) to check whether a similar workflow already exists. If the user asks to "redesign", "redo", or "update" an existing workflow, call \`update_workflow\` on the matching row instead of creating a new one. \`create_workflow\` performs its own near-duplicate check and will return \`{status: "similar-found", matches: [...]}\` instead of inserting when it finds one — when that happens, surface the matches to the user and confirm intent. Only pass \`force: true\` to \`create_workflow\` when the user has explicitly confirmed they want a second workflow alongside a similar one (e.g., "v2", "alternate approach").
|
|
132
133
|
- When a working directory is specified, always create files relative to it. Never assume the git root is the working directory — they may differ in worktree environments.
|
|
133
134
|
|
|
@@ -7,6 +7,54 @@ import { ok, err, type ToolContext } from "./helpers";
|
|
|
7
7
|
|
|
8
8
|
const VALID_PROJECT_STATUSES = ["active", "paused", "completed"] as const;
|
|
9
9
|
|
|
10
|
+
export interface SimilarProjectMatch {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Find existing projects that duplicate a candidate by name.
|
|
18
|
+
*
|
|
19
|
+
* Projects are top-level (not scoped like workflows), so this scans all of
|
|
20
|
+
* them. Unlike workflow dedup, there is no fuzzy step-text signal to compare —
|
|
21
|
+
* a project is just a name + description — so this is an exact, case- and
|
|
22
|
+
* whitespace-insensitive name match only. That precisely targets the observed
|
|
23
|
+
* compose bug: a long chat truncates the earlier `create_project` call out of
|
|
24
|
+
* the sliding-window context, so the model re-creates the same named client
|
|
25
|
+
* ("Northstar CRE") instead of reusing it. Fuzzy matching would add
|
|
26
|
+
* false-positive risk with no evidence it is needed (engineering principle #6).
|
|
27
|
+
*
|
|
28
|
+
* Used by `create_project` to warn the model before blindly inserting; bypass
|
|
29
|
+
* with `force: true` when the user genuinely wants a second same-named project.
|
|
30
|
+
*/
|
|
31
|
+
export async function findSimilarProjects(
|
|
32
|
+
candidateName: string
|
|
33
|
+
): Promise<SimilarProjectMatch[]> {
|
|
34
|
+
const candidateNameLower = candidateName.trim().toLowerCase();
|
|
35
|
+
if (!candidateNameLower) return [];
|
|
36
|
+
|
|
37
|
+
const existing = await db
|
|
38
|
+
.select({
|
|
39
|
+
id: projects.id,
|
|
40
|
+
name: projects.name,
|
|
41
|
+
description: projects.description,
|
|
42
|
+
})
|
|
43
|
+
.from(projects);
|
|
44
|
+
|
|
45
|
+
const matches: SimilarProjectMatch[] = [];
|
|
46
|
+
for (const row of existing) {
|
|
47
|
+
if (row.name.trim().toLowerCase() === candidateNameLower) {
|
|
48
|
+
matches.push({
|
|
49
|
+
id: row.id,
|
|
50
|
+
name: row.name,
|
|
51
|
+
reason: `Same name: "${row.name}"`,
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return matches;
|
|
56
|
+
}
|
|
57
|
+
|
|
10
58
|
export function projectTools(ctx: ToolContext) {
|
|
11
59
|
return [
|
|
12
60
|
defineTool(
|
|
@@ -57,9 +105,32 @@ export function projectTools(ctx: ToolContext) {
|
|
|
57
105
|
.max(500)
|
|
58
106
|
.optional()
|
|
59
107
|
.describe("Absolute path to the project's working directory"),
|
|
108
|
+
force: z
|
|
109
|
+
.boolean()
|
|
110
|
+
.optional()
|
|
111
|
+
.describe(
|
|
112
|
+
"Set to true to create a project even when one with the same name already exists. Only use this when the user has explicitly confirmed they want a second same-named project. Default false — normally you should reuse the existing project returned by the near-duplicate check (its id) instead of creating a duplicate."
|
|
113
|
+
),
|
|
60
114
|
},
|
|
61
115
|
async (args) => {
|
|
62
116
|
try {
|
|
117
|
+
// Dedup guard: in a long compose conversation the sliding-window
|
|
118
|
+
// context can evict the earlier create_project call, so the model
|
|
119
|
+
// re-creates the same named client instead of reusing it. Check for
|
|
120
|
+
// an existing same-named project before inserting; pass force=true to
|
|
121
|
+
// bypass. Mirrors the create_workflow near-duplicate pattern.
|
|
122
|
+
if (!args.force) {
|
|
123
|
+
const similar = await findSimilarProjects(args.name);
|
|
124
|
+
if (similar.length > 0) {
|
|
125
|
+
return ok({
|
|
126
|
+
status: "similar-found",
|
|
127
|
+
message:
|
|
128
|
+
"A project with this name already exists. Reuse it by its id for subsequent artifacts (profiles, tables, workflows), or pass force=true to create a separate same-named project.",
|
|
129
|
+
matches: similar,
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
63
134
|
const now = new Date();
|
|
64
135
|
const id = crypto.randomUUID();
|
|
65
136
|
|
|
@@ -83,3 +83,21 @@ export function buildNextLaunchArgs({
|
|
|
83
83
|
export function buildSidecarUrl(port: number, host = SIDECAR_LOOPBACK_HOST): string {
|
|
84
84
|
return `http://${host}:${port}`;
|
|
85
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* True when `host` is NOT a loopback address — i.e. binding to it exposes the
|
|
89
|
+
* server beyond the local machine. Relay is local-first with light auth, so the
|
|
90
|
+
* CLI warns before binding to such a host (`--hostname 0.0.0.0`, a LAN IP, a
|
|
91
|
+
* hostname, etc.). This is an advisory gate, not access control, so it errs
|
|
92
|
+
* toward warning: anything not provably loopback returns true.
|
|
93
|
+
*
|
|
94
|
+
* Loopback = `localhost`, IPv6 `::1`, or any `127.0.0.0/8` address (Linux lets
|
|
95
|
+
* you bind e.g. `127.0.0.5`). `0.0.0.0` / `::` are INADDR_ANY ("all
|
|
96
|
+
* interfaces") — the most exposing choice — and are treated as non-loopback.
|
|
97
|
+
*/
|
|
98
|
+
export function isNonLoopbackHost(host: string): boolean {
|
|
99
|
+
const h = host.trim().toLowerCase();
|
|
100
|
+
if (h === "localhost" || h === "::1") return false;
|
|
101
|
+
if (/^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(h)) return false;
|
|
102
|
+
return true;
|
|
103
|
+
}
|