add map & eq to getServiceInterface

This commit is contained in:
Aiden McClelland
2025-12-17 19:14:21 -07:00
parent 7b3c74179b
commit 5a9510238e
5 changed files with 160 additions and 106 deletions

View File

@@ -1,13 +1,13 @@
import { object } from "ts-matches" import { object } from "ts-matches"
export function deepEqual(...args: unknown[]) { export function deepEqual(...args: unknown[]) {
if (!object.test(args[args.length - 1])) return args[args.length - 1]
const objects = args.filter(object.test) const objects = args.filter(object.test)
if (objects.length === 0) { if (objects.length === 0) {
for (const x of args) if (x !== args[0]) return false for (const x of args) if (x !== args[0]) return false
return true return true
} }
if (objects.length !== args.length) return false if (objects.length !== args.length) return false
if (objects.some(Array.isArray) && !objects.every(Array.isArray)) return false
const allKeys = new Set(objects.flatMap((x) => Object.keys(x))) const allKeys = new Set(objects.flatMap((x) => Object.keys(x)))
for (const key of allKeys) { for (const key of allKeys) {
for (const x of objects) { for (const x of objects) {

View File

@@ -1,9 +1,10 @@
import { ServiceInterfaceType } from "../types" import { PackageId, ServiceInterfaceId, ServiceInterfaceType } from "../types"
import { knownProtocols } from "../interfaces/Host" import { knownProtocols } from "../interfaces/Host"
import { AddressInfo, Host, Hostname, HostnameInfo } from "../types" import { AddressInfo, Host, Hostname, HostnameInfo } from "../types"
import { Effects } from "../Effects" import { Effects } from "../Effects"
import { DropGenerator, DropPromise } from "./Drop" import { DropGenerator, DropPromise } from "./Drop"
import { IpAddress, IPV6_LINK_LOCAL } from "./ip" import { IpAddress, IPV6_LINK_LOCAL } from "./ip"
import { deepEqual } from "./deepEqual"
export type UrlString = string export type UrlString = string
export type HostId = string export type HostId = string
@@ -227,7 +228,7 @@ function filterRec(
(kind.has("ipv4") && h.kind === "ip" && h.hostname.kind === "ipv4") || (kind.has("ipv4") && h.kind === "ip" && h.hostname.kind === "ipv4") ||
(kind.has("ipv6") && h.kind === "ip" && h.hostname.kind === "ipv6") || (kind.has("ipv6") && h.kind === "ip" && h.hostname.kind === "ipv6") ||
(kind.has("localhost") && (kind.has("localhost") &&
["localhost", "127.0.0.1", "[::1]"].includes(h.hostname.value)) || ["localhost", "127.0.0.1", "::1"].includes(h.hostname.value)) ||
(kind.has("link-local") && (kind.has("link-local") &&
h.kind === "ip" && h.kind === "ip" &&
h.hostname.kind === "ipv6" && h.hostname.kind === "ipv6" &&
@@ -328,28 +329,28 @@ const makeInterfaceFilled = async ({
return interfaceFilled return interfaceFilled
} }
export class GetServiceInterface { export class GetServiceInterface<Mapped = ServiceInterfaceFilled | null> {
constructor( constructor(
readonly effects: Effects, readonly effects: Effects,
readonly opts: { id: string; packageId?: string }, readonly opts: { id: string; packageId?: string },
readonly map: (interfaces: ServiceInterfaceFilled | null) => Mapped,
readonly eq: (a: Mapped, b: Mapped) => boolean,
) {} ) {}
/** /**
* Returns the requested service interface. Reruns the context from which it has been called if the underlying value changes * Returns the requested service interface. Reruns the context from which it has been called if the underlying value changes
*/ */
async const() { async const() {
const { id, packageId } = this.opts let abort = new AbortController()
const callback = const watch = this.watch(abort.signal)
this.effects.constRetry && const res = await watch.next()
(() => this.effects.constRetry && this.effects.constRetry()) if (this.effects.constRetry) {
const interfaceFilled = await makeInterfaceFilled({ watch.next().then(() => {
effects: this.effects, abort.abort()
id, this.effects.constRetry && this.effects.constRetry()
packageId, })
callback, }
}) return res.value
return interfaceFilled
} }
/** /**
* Returns the requested service interface. Does nothing if the value changes * Returns the requested service interface. Does nothing if the value changes
@@ -362,10 +363,11 @@ export class GetServiceInterface {
packageId, packageId,
}) })
return interfaceFilled return this.map(interfaceFilled)
} }
private async *watchGen(abort?: AbortSignal) { private async *watchGen(abort?: AbortSignal) {
let prev = null as { value: Mapped } | null
const { id, packageId } = this.opts const { id, packageId } = this.opts
const resolveCell = { resolve: () => {} } const resolveCell = { resolve: () => {} }
this.effects.onLeaveContext(() => { this.effects.onLeaveContext(() => {
@@ -378,12 +380,17 @@ export class GetServiceInterface {
callback = resolve callback = resolve
resolveCell.resolve = resolve resolveCell.resolve = resolve
}) })
yield await makeInterfaceFilled({ const next = this.map(
effects: this.effects, await makeInterfaceFilled({
id, effects: this.effects,
packageId, id,
callback, packageId,
}) callback,
}),
)
if (!prev || !this.eq(prev.value, next)) {
yield next
}
await waitForNext await waitForNext
} }
} }
@@ -391,9 +398,7 @@ export class GetServiceInterface {
/** /**
* Watches the requested service interface. Returns an async iterator that yields whenever the value changes * Watches the requested service interface. Returns an async iterator that yields whenever the value changes
*/ */
watch( watch(abort?: AbortSignal): AsyncGenerator<Mapped, void, unknown> {
abort?: AbortSignal,
): AsyncGenerator<ServiceInterfaceFilled | null, void, unknown> {
const ctrl = new AbortController() const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort()) abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())
@@ -404,7 +409,7 @@ export class GetServiceInterface {
*/ */
onChange( onChange(
callback: ( callback: (
value: ServiceInterfaceFilled | null, value: Mapped | null,
error?: Error, error?: Error,
) => { cancel: boolean } | Promise<{ cancel: boolean }>, ) => { cancel: boolean } | Promise<{ cancel: boolean }>,
) { ) {
@@ -437,9 +442,7 @@ export class GetServiceInterface {
/** /**
* Watches the requested service interface. Returns when the predicate is true * Watches the requested service interface. Returns when the predicate is true
*/ */
waitFor( waitFor(pred: (value: Mapped) => boolean): Promise<Mapped> {
pred: (value: ServiceInterfaceFilled | null) => boolean,
): Promise<ServiceInterfaceFilled | null> {
const ctrl = new AbortController() const ctrl = new AbortController()
return DropPromise.of( return DropPromise.of(
Promise.resolve().then(async () => { Promise.resolve().then(async () => {
@@ -448,15 +451,57 @@ export class GetServiceInterface {
return next return next
} }
} }
return null throw new Error("context left before predicate passed")
}), }),
() => ctrl.abort(), () => ctrl.abort(),
) )
} }
} }
export function getOwnServiceInterface(
effects: Effects,
id: ServiceInterfaceId,
): GetServiceInterface
export function getOwnServiceInterface<Mapped>(
effects: Effects,
id: ServiceInterfaceId,
map: (interfaces: ServiceInterfaceFilled | null) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceInterface<Mapped>
export function getOwnServiceInterface<Mapped>(
effects: Effects,
id: ServiceInterfaceId,
map?: (interfaces: ServiceInterfaceFilled | null) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceInterface<Mapped> {
return new GetServiceInterface(
effects,
{ id },
map ?? ((a) => a as Mapped),
eq ?? ((a, b) => deepEqual(a, b)),
)
}
export function getServiceInterface( export function getServiceInterface(
effects: Effects, effects: Effects,
opts: { id: string; packageId?: string }, opts: { id: ServiceInterfaceId; packageId: PackageId },
) { ): GetServiceInterface
return new GetServiceInterface(effects, opts) export function getServiceInterface<Mapped>(
effects: Effects,
opts: { id: ServiceInterfaceId; packageId: PackageId },
map: (interfaces: ServiceInterfaceFilled | null) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceInterface<Mapped>
export function getServiceInterface<Mapped>(
effects: Effects,
opts: { id: ServiceInterfaceId; packageId: PackageId },
map?: (interfaces: ServiceInterfaceFilled | null) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceInterface<Mapped> {
return new GetServiceInterface(
effects,
opts,
map ?? ((a) => a as Mapped),
eq ?? ((a, b) => deepEqual(a, b)),
)
} }

View File

@@ -1,4 +1,6 @@
import { Effects } from "../Effects" import { Effects } from "../Effects"
import { PackageId } from "../osBindings"
import { deepEqual } from "./deepEqual"
import { DropGenerator, DropPromise } from "./Drop" import { DropGenerator, DropPromise } from "./Drop"
import { import {
ServiceInterfaceFilled, ServiceInterfaceFilled,
@@ -41,28 +43,28 @@ const makeManyInterfaceFilled = async ({
return serviceInterfacesFilled return serviceInterfacesFilled
} }
export class GetServiceInterfaces { export class GetServiceInterfaces<Mapped = ServiceInterfaceFilled[]> {
constructor( constructor(
readonly effects: Effects, readonly effects: Effects,
readonly opts: { packageId?: string }, readonly opts: { packageId?: string },
readonly map: (interfaces: ServiceInterfaceFilled[]) => Mapped,
readonly eq: (a: Mapped, b: Mapped) => boolean,
) {} ) {}
/** /**
* Returns the service interfaces for the package. Reruns the context from which it has been called if the underlying value changes * Returns the service interfaces for the package. Reruns the context from which it has been called if the underlying value changes
*/ */
async const() { async const() {
const { packageId } = this.opts let abort = new AbortController()
const callback = const watch = this.watch(abort.signal)
this.effects.constRetry && const res = await watch.next()
(() => this.effects.constRetry && this.effects.constRetry()) if (this.effects.constRetry) {
const interfaceFilled: ServiceInterfaceFilled[] = watch.next().then(() => {
await makeManyInterfaceFilled({ abort.abort()
effects: this.effects, this.effects.constRetry && this.effects.constRetry()
packageId,
callback,
}) })
}
return interfaceFilled return res.value
} }
/** /**
* Returns the service interfaces for the package. Does nothing if the value changes * Returns the service interfaces for the package. Does nothing if the value changes
@@ -75,10 +77,11 @@ export class GetServiceInterfaces {
packageId, packageId,
}) })
return interfaceFilled return this.map(interfaceFilled)
} }
private async *watchGen(abort?: AbortSignal) { private async *watchGen(abort?: AbortSignal) {
let prev = null as { value: Mapped } | null
const { packageId } = this.opts const { packageId } = this.opts
const resolveCell = { resolve: () => {} } const resolveCell = { resolve: () => {} }
this.effects.onLeaveContext(() => { this.effects.onLeaveContext(() => {
@@ -91,11 +94,16 @@ export class GetServiceInterfaces {
callback = resolve callback = resolve
resolveCell.resolve = resolve resolveCell.resolve = resolve
}) })
yield await makeManyInterfaceFilled({ const next = this.map(
effects: this.effects, await makeManyInterfaceFilled({
packageId, effects: this.effects,
callback, packageId,
}) callback,
}),
)
if (!prev || !this.eq(prev.value, next)) {
yield next
}
await waitForNext await waitForNext
} }
} }
@@ -103,9 +111,7 @@ export class GetServiceInterfaces {
/** /**
* Watches the service interfaces for the package. Returns an async iterator that yields whenever the value changes * Watches the service interfaces for the package. Returns an async iterator that yields whenever the value changes
*/ */
watch( watch(abort?: AbortSignal): AsyncGenerator<Mapped, void, unknown> {
abort?: AbortSignal,
): AsyncGenerator<ServiceInterfaceFilled[], void, unknown> {
const ctrl = new AbortController() const ctrl = new AbortController()
abort?.addEventListener("abort", () => ctrl.abort()) abort?.addEventListener("abort", () => ctrl.abort())
return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort()) return DropGenerator.of(this.watchGen(ctrl.signal), () => ctrl.abort())
@@ -116,7 +122,7 @@ export class GetServiceInterfaces {
*/ */
onChange( onChange(
callback: ( callback: (
value: ServiceInterfaceFilled[] | null, value: Mapped | null,
error?: Error, error?: Error,
) => { cancel: boolean } | Promise<{ cancel: boolean }>, ) => { cancel: boolean } | Promise<{ cancel: boolean }>,
) { ) {
@@ -149,9 +155,7 @@ export class GetServiceInterfaces {
/** /**
* Watches the service interfaces for the package. Returns when the predicate is true * Watches the service interfaces for the package. Returns when the predicate is true
*/ */
waitFor( waitFor(pred: (value: Mapped) => boolean): Promise<Mapped> {
pred: (value: ServiceInterfaceFilled[] | null) => boolean,
): Promise<ServiceInterfaceFilled[] | null> {
const ctrl = new AbortController() const ctrl = new AbortController()
return DropPromise.of( return DropPromise.of(
Promise.resolve().then(async () => { Promise.resolve().then(async () => {
@@ -160,15 +164,52 @@ export class GetServiceInterfaces {
return next return next
} }
} }
return null throw new Error("context left before predicate passed")
}), }),
() => ctrl.abort(), () => ctrl.abort(),
) )
} }
} }
export function getOwnServiceInterfaces(effects: Effects): GetServiceInterfaces
export function getOwnServiceInterfaces<Mapped>(
effects: Effects,
map: (interfaces: ServiceInterfaceFilled[]) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceInterfaces<Mapped>
export function getOwnServiceInterfaces<Mapped>(
effects: Effects,
map?: (interfaces: ServiceInterfaceFilled[]) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceInterfaces<Mapped> {
return new GetServiceInterfaces(
effects,
{},
map ?? ((a) => a as Mapped),
eq ?? ((a, b) => deepEqual(a, b)),
)
}
export function getServiceInterfaces( export function getServiceInterfaces(
effects: Effects, effects: Effects,
opts: { packageId?: string }, opts: { packageId: PackageId },
) { ): GetServiceInterfaces
return new GetServiceInterfaces(effects, opts) export function getServiceInterfaces<Mapped>(
effects: Effects,
opts: { packageId: PackageId },
map: (interfaces: ServiceInterfaceFilled[]) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceInterfaces<Mapped>
export function getServiceInterfaces<Mapped>(
effects: Effects,
opts: { packageId: PackageId },
map?: (interfaces: ServiceInterfaceFilled[]) => Mapped,
eq?: (a: Mapped, b: Mapped) => boolean,
): GetServiceInterfaces<Mapped> {
return new GetServiceInterfaces(
effects,
opts,
map ?? ((a) => a as Mapped),
eq ?? ((a, b) => deepEqual(a, b)),
)
} }

View File

@@ -60,6 +60,11 @@ import {
setupOnUninit, setupOnUninit,
} from "../../base/lib/inits" } from "../../base/lib/inits"
import { DropGenerator } from "../../base/lib/util/Drop" import { DropGenerator } from "../../base/lib/util/Drop"
import {
getOwnServiceInterface,
ServiceInterfaceFilled,
} from "../../base/lib/util/getServiceInterface"
import { getOwnServiceInterfaces } from "../../base/lib/util/getServiceInterfaces"
export const OSVersion = testTypeVersion("0.4.0-alpha.16") export const OSVersion = testTypeVersion("0.4.0-alpha.16")
@@ -170,20 +175,10 @@ export class StartSdk<Manifest extends T.SDKManifest> {
packageIds?: DependencyId[], packageIds?: DependencyId[],
) => Promise<CheckDependencies<DependencyId>>, ) => Promise<CheckDependencies<DependencyId>>,
serviceInterface: { serviceInterface: {
getOwn: <E extends Effects>(effects: E, id: ServiceInterfaceId) => getOwn: getOwnServiceInterface,
getServiceInterface(effects, { get: getServiceInterface,
id, getAllOwn: getOwnServiceInterfaces,
}), getAll: getServiceInterfaces,
get: <E extends Effects>(
effects: E,
opts: { id: ServiceInterfaceId; packageId: PackageId },
) => getServiceInterface(effects, opts),
getAllOwn: <E extends Effects>(effects: E) =>
getServiceInterfaces(effects, {}),
getAll: <E extends Effects>(
effects: E,
opts: { packageId: PackageId },
) => getServiceInterfaces(effects, opts),
}, },
getContainerIp: ( getContainerIp: (
effects: T.Effects, effects: T.Effects,

View File

@@ -4,38 +4,11 @@ import * as TOML from "@iarna/toml"
import * as INI from "ini" import * as INI from "ini"
import * as T from "../../../base/lib/types" import * as T from "../../../base/lib/types"
import * as fs from "node:fs/promises" import * as fs from "node:fs/promises"
import { asError } from "../../../base/lib/util" import { asError, deepEqual } from "../../../base/lib/util"
import { DropGenerator, DropPromise } from "../../../base/lib/util/Drop" import { DropGenerator, DropPromise } from "../../../base/lib/util/Drop"
const previousPath = /(.+?)\/([^/]*)$/ const previousPath = /(.+?)\/([^/]*)$/
const deepEq = (left: unknown, right: unknown) => {
if (left === right) return true
if (Array.isArray(left) && Array.isArray(right)) {
if (left.length === right.length) {
for (const idx in left) {
if (!deepEq(left[idx], right[idx])) return false
}
return true
}
} else if (
typeof left === "object" &&
typeof right === "object" &&
left &&
right
) {
const keys = new Set<keyof typeof left | keyof typeof right>([
...(Object.keys(left) as (keyof typeof left)[]),
...(Object.keys(right) as (keyof typeof right)[]),
])
for (let key of keys) {
if (!deepEq(left[key], right[key])) return false
}
return true
}
return false
}
const exists = (path: string) => const exists = (path: string) =>
fs.access(path).then( fs.access(path).then(
() => true, () => true,
@@ -374,7 +347,7 @@ export class FileHelper<A> {
eq?: (left: any, right: any) => boolean, eq?: (left: any, right: any) => boolean,
): ReadType<any> { ): ReadType<any> {
map = map ?? ((a: A) => a) map = map ?? ((a: A) => a)
eq = eq ?? deepEq eq = eq ?? deepEqual
return { return {
once: () => this.readOnce(map), once: () => this.readOnce(map),
const: (effects: T.Effects) => this.readConst(effects, map, eq), const: (effects: T.Effects) => this.readConst(effects, map, eq),