astrospatial-services 0.0.0-stage → 0.1.0

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
+ Astrospatial Services Commercial Licensing
2
+
3
+ Commercial use of Astrospatial Services requires a separate written agreement with
4
+ the copyright holder.
5
+
6
+ Examples of commercial use include, without limitation:
7
+
8
+ - integration into proprietary software
9
+ - use in internal business systems or business operations
10
+ - paid services, hosted offerings, SaaS, or managed services
11
+ - consulting deliverables transferred to a client
12
+ - OEM, embedded, or bundled redistribution
13
+ - use by or for a company, institution, contractor, or client in connection
14
+ with direct or indirect revenue generation
15
+
16
+ Unless and until a commercial agreement is executed, no commercial rights are
17
+ granted by this repository or package.
18
+
19
+ Contact the copyright holder to discuss commercial licensing terms.
package/LICENSE.md ADDED
@@ -0,0 +1,49 @@
1
+ # Astrospatial Services Licensing
2
+
3
+ Copyright (C) Fabrizio Giordano
4
+
5
+ Astrospatial Services is distributed under a dual-license model.
6
+
7
+ You may use Astrospatial Services under either of the following licensing options.
8
+
9
+ ## 1. GNU Affero General Public License v3.0
10
+
11
+ Astrospatial Services is available under the GNU Affero General Public License
12
+ version 3 (`AGPL-3.0-only`).
13
+
14
+ This is an open-source license and permits commercial and non-commercial use,
15
+ modification, redistribution, and network-based use, subject to the terms and
16
+ obligations of the AGPL-3.0.
17
+
18
+ The complete license text is available in:
19
+
20
+ `LICENSE-AGPL.md`
21
+
22
+ ## 2. Commercial license
23
+
24
+ A separate commercial license is available for organizations or individuals
25
+ that want to use Astrospatial Services under terms that do not impose the
26
+ requirements of the AGPL-3.0.
27
+
28
+ Typical commercial-license use cases include proprietary products, closed-source
29
+ applications, SaaS offerings, embedded products, OEM redistribution, or other
30
+ situations where AGPL-3.0 obligations are not suitable.
31
+
32
+ See:
33
+
34
+ `LICENSE-COMMERCIAL.md`
35
+
36
+ for commercial licensing information.
37
+
38
+ ## Choosing a license
39
+
40
+ You may choose either licensing track according to your requirements.
41
+
42
+ Use of Astrospatial Services under the AGPL-3.0 does not require a commercial
43
+ license, including for commercial activity, provided that the AGPL-3.0 terms
44
+ are satisfied.
45
+
46
+ The commercial license provides an alternative licensing path for proprietary
47
+ or otherwise incompatible use cases.
48
+
49
+ Third-party dependencies remain governed by their respective licenses.
package/README.md CHANGED
@@ -1,3 +1,47 @@
1
- # Temporary Holding Version
1
+ # astrospatial-services
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Render-independent TypeScript services for scientific data access across astronomy, Earth observation and planetary applications.
4
+
5
+ **Status:** 0.1.0-snapshot; foundation and CDS Sesame name resolution only. TAP and HiPS discovery are planned, **not implemented**.
6
+
7
+ ## Requirements
8
+
9
+ Node.js >=22, npm, TypeScript 5.x. ESM package; browser-compatible APIs (`fetch`, `AbortSignal`) with no dependency on AstroViewer.
10
+
11
+ ## Development
12
+
13
+ ```sh
14
+ npm ci
15
+ npm test
16
+ npm pack --dry-run
17
+ ```
18
+
19
+ ## Name resolution
20
+
21
+ ```ts
22
+ import { SesameService } from 'astrospatial-services/astronomy/name-resolver';
23
+ const resolver = new SesameService();
24
+ const target = await resolver.resolve('M51'); // { name, raDeg, decDeg, resolver? } or null
25
+ ```
26
+
27
+ `resolve()` returns `null` for an unresolved name. Transport, HTTP, timeout and malformed-response failures throw `ServiceError` with a `code`. Endpoint and transport are injectable; callers may pass an `AbortSignal`.
28
+
29
+ The XML parser is a small, deliberately limited parser for Sesame's `Target/Resolver/jradeg/jdedeg` response. It is not a general-purpose XML parser; richer Sesame responses or VOTable will require a dedicated parser and fixtures.
30
+
31
+ ### Live integration
32
+
33
+ ```sh
34
+ RUN_LIVE_TESTS=1 npm run test:live
35
+ ```
36
+
37
+ Live tests require external CDS availability and are excluded from ordinary CI.
38
+
39
+ ## Roadmap
40
+
41
+ - HiPS node discovery and `hipslist`/`properties` parsing, independent of AstroViewer's `HiPSDescriptor`.
42
+ - TAP Sync/ADQL with typed tabular results, then TAP Async and VOTable metadata.
43
+ - Later: STAC/OGC Earth observation and planetary/PDS discovery.
44
+
45
+ ## Licensing
46
+
47
+ Dual-licensed under AGPL-3.0-only or a separate commercial license. See LICENSE.md.
@@ -0,0 +1,37 @@
1
+ import { type HttpTransport } from "../../core/HttpTransport.js";
2
+ export type HiPSMetadata = Record<string, string>;
3
+ export interface HiPSSurveyEntry {
4
+ serviceUrl: string;
5
+ creatorDid?: string;
6
+ releaseDate?: string;
7
+ metadata: HiPSMetadata;
8
+ }
9
+ export interface HiPSProperties {
10
+ serviceUrl: string;
11
+ creatorDid?: string;
12
+ title?: string;
13
+ order?: number;
14
+ frame?: string;
15
+ tileFormats: string[];
16
+ metadata: HiPSMetadata;
17
+ }
18
+ export interface HiPSNodeServiceOptions {
19
+ transport?: HttpTransport;
20
+ timeoutMs?: number;
21
+ }
22
+ export interface HiPSRequestOptions {
23
+ signal?: AbortSignal;
24
+ }
25
+ /** Parse HiPS key=value lines; retain unknown keys and values containing '='. */
26
+ export declare function parseHiPSMetadata(text: string): HiPSMetadata;
27
+ /** Parse CDS-style hipslist records separated by blank lines. */
28
+ export declare function parseHiPSList(text: string): HiPSSurveyEntry[];
29
+ export declare function parseHiPSProperties(text: string, serviceUrl: string): HiPSProperties;
30
+ /** HTTP-only HiPS discovery and properties, independent of rendering engines. */
31
+ export declare class HiPSNodeService {
32
+ private readonly transport;
33
+ private readonly timeoutMs;
34
+ constructor(options?: HiPSNodeServiceOptions);
35
+ listSurveys(nodeUrl: string, options?: HiPSRequestOptions): Promise<HiPSSurveyEntry[]>;
36
+ getProperties(serviceUrl: string, options?: HiPSRequestOptions): Promise<HiPSProperties>;
37
+ }
@@ -0,0 +1,104 @@
1
+ import { FetchHttpTransport } from "../../core/HttpTransport.js";
2
+ import { ServiceError } from "../../core/ServiceError.js";
3
+ /** Parse HiPS key=value lines; retain unknown keys and values containing '='. */
4
+ export function parseHiPSMetadata(text) {
5
+ const metadata = {};
6
+ for (const line of text.split(/\r?\n/)) {
7
+ const trimmed = line.trim();
8
+ if (!trimmed || trimmed.startsWith('#'))
9
+ continue;
10
+ const separator = line.indexOf('=');
11
+ if (separator < 0)
12
+ continue;
13
+ const key = line.slice(0, separator).trim();
14
+ if (!key)
15
+ continue;
16
+ metadata[key] = line.slice(separator + 1).trim();
17
+ }
18
+ return metadata;
19
+ }
20
+ /** Parse CDS-style hipslist records separated by blank lines. */
21
+ export function parseHiPSList(text) {
22
+ if (typeof text !== 'string')
23
+ throw new TypeError('HiPS list must be text');
24
+ const blocks = text.split(/(?:\r?\n)[ \t]*(?:\r?\n)+/);
25
+ const entries = [];
26
+ for (const block of blocks) {
27
+ const metadata = parseHiPSMetadata(block);
28
+ const serviceUrl = metadata.hips_service_url;
29
+ if (!serviceUrl)
30
+ continue;
31
+ entries.push({
32
+ serviceUrl,
33
+ ...(metadata.creator_did ? { creatorDid: metadata.creator_did } : {}),
34
+ ...(metadata.hips_release_date ? { releaseDate: metadata.hips_release_date } : {}),
35
+ metadata,
36
+ });
37
+ }
38
+ return entries;
39
+ }
40
+ function normalizeHttpUrl(url, filename) {
41
+ const value = url.trim();
42
+ let parsed;
43
+ try {
44
+ parsed = new URL(value);
45
+ }
46
+ catch {
47
+ throw new TypeError(`Invalid HiPS URL: ${url}`);
48
+ }
49
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
50
+ throw new TypeError('HiPS URL must use HTTP or HTTPS');
51
+ }
52
+ if (parsed.username || parsed.password)
53
+ throw new TypeError('HiPS URL must not contain credentials');
54
+ if (parsed.pathname.endsWith('/' + filename))
55
+ return parsed.toString();
56
+ if (parsed.search || parsed.hash)
57
+ throw new TypeError('HiPS base URL must not have query or fragment');
58
+ parsed.pathname = parsed.pathname.replace(/\/+$/, '') + '/' + filename;
59
+ return parsed.toString();
60
+ }
61
+ export function parseHiPSProperties(text, serviceUrl) {
62
+ const metadata = parseHiPSMetadata(text);
63
+ if (Object.keys(metadata).length === 0) {
64
+ throw new ServiceError('INVALID_RESPONSE', 'Empty or invalid HiPS properties');
65
+ }
66
+ const orderText = metadata.hips_order;
67
+ const order = orderText === undefined ? undefined : Number(orderText);
68
+ if (orderText !== undefined && (!Number.isInteger(order) || order < 0)) {
69
+ throw new ServiceError('INVALID_RESPONSE', 'Invalid hips_order');
70
+ }
71
+ return {
72
+ serviceUrl,
73
+ ...(metadata.creator_did ? { creatorDid: metadata.creator_did } : {}),
74
+ ...(metadata.obs_title ? { title: metadata.obs_title } : {}),
75
+ ...(order !== undefined ? { order } : {}),
76
+ ...(metadata.hips_frame ? { frame: metadata.hips_frame } : {}),
77
+ tileFormats: metadata.hips_tile_format?.split(/\s+/).filter(Boolean) ?? [],
78
+ metadata,
79
+ };
80
+ }
81
+ /** HTTP-only HiPS discovery and properties, independent of rendering engines. */
82
+ export class HiPSNodeService {
83
+ transport;
84
+ timeoutMs;
85
+ constructor(options = {}) {
86
+ this.transport = options.transport ?? new FetchHttpTransport();
87
+ this.timeoutMs = options.timeoutMs ?? 10000;
88
+ }
89
+ async listSurveys(nodeUrl, options = {}) {
90
+ const url = normalizeHttpUrl(nodeUrl, 'hipslist');
91
+ const text = await this.transport.getText(url, { signal: options.signal, timeoutMs: this.timeoutMs });
92
+ const surveys = parseHiPSList(text);
93
+ if (surveys.length === 0 && text.trim()) {
94
+ throw new ServiceError('INVALID_RESPONSE', `No HiPS entries found at ${url}`);
95
+ }
96
+ return surveys;
97
+ }
98
+ async getProperties(serviceUrl, options = {}) {
99
+ const url = normalizeHttpUrl(serviceUrl, 'properties');
100
+ const text = await this.transport.getText(url, { signal: options.signal, timeoutMs: this.timeoutMs });
101
+ const base = url.slice(0, -'/properties'.length);
102
+ return parseHiPSProperties(text, base);
103
+ }
104
+ }
@@ -0,0 +1,2 @@
1
+ export { HiPSNodeService, parseHiPSList, parseHiPSMetadata, parseHiPSProperties } from './HiPSNodeService.js';
2
+ export type { HiPSMetadata, HiPSSurveyEntry, HiPSProperties, HiPSNodeServiceOptions, HiPSRequestOptions } from './HiPSNodeService.js';
@@ -0,0 +1 @@
1
+ export { HiPSNodeService, parseHiPSList, parseHiPSMetadata, parseHiPSProperties } from './HiPSNodeService.js';
@@ -0,0 +1,29 @@
1
+ import { type HttpTransport } from '../../core/HttpTransport.js';
2
+ export interface ResolvedTarget {
3
+ name: string;
4
+ raDeg: number;
5
+ decDeg: number;
6
+ resolver?: string;
7
+ }
8
+ export interface NameResolver {
9
+ resolve(name: string, options?: {
10
+ signal?: AbortSignal;
11
+ }): Promise<ResolvedTarget | null>;
12
+ }
13
+ export interface SesameServiceOptions {
14
+ endpoint?: string;
15
+ timeoutMs?: number;
16
+ transport?: HttpTransport;
17
+ }
18
+ /** CDS Sesame XML resolver; coordinates are decimal degrees (ICRS/J2000 as supplied by CDS). */
19
+ export declare class SesameService implements NameResolver {
20
+ private readonly endpoint;
21
+ private readonly timeoutMs;
22
+ private readonly transport;
23
+ constructor(options?: SesameServiceOptions);
24
+ resolve(name: string, options?: {
25
+ signal?: AbortSignal;
26
+ }): Promise<ResolvedTarget | null>;
27
+ }
28
+ /** Parse Sesame's <Target><Resolver><jradeg>/<jdedeg> XML without browser-only DOMParser. */
29
+ export declare function parseSesameXml(xml: string, name: string): ResolvedTarget | null;
@@ -0,0 +1,45 @@
1
+ import { FetchHttpTransport } from '../../core/HttpTransport.js';
2
+ import { ServiceError } from '../../core/ServiceError.js';
3
+ /** CDS Sesame XML resolver; coordinates are decimal degrees (ICRS/J2000 as supplied by CDS). */
4
+ export class SesameService {
5
+ endpoint;
6
+ timeoutMs;
7
+ transport;
8
+ constructor(options = {}) {
9
+ this.endpoint = options.endpoint ?? 'https://cdsweb.u-strasbg.fr/cgi-bin/nph-sesame/-ox?';
10
+ this.timeoutMs = options.timeoutMs ?? 10000;
11
+ this.transport = options.transport ?? new FetchHttpTransport();
12
+ }
13
+ async resolve(name, options = {}) {
14
+ if (!name.trim())
15
+ throw new TypeError('Target name must not be empty');
16
+ const raw = await this.transport.getText(this.endpoint + encodeURIComponent(name.trim()), {
17
+ signal: options.signal,
18
+ timeoutMs: this.timeoutMs,
19
+ });
20
+ return parseSesameXml(raw, name.trim());
21
+ }
22
+ }
23
+ /** Parse Sesame's <Target><Resolver><jradeg>/<jdedeg> XML without browser-only DOMParser. */
24
+ export function parseSesameXml(xml, name) {
25
+ if (!xml.trim() || !/<Sesame(?:\s|>)/i.test(xml)) {
26
+ throw new ServiceError('INVALID_RESPONSE', 'Unexpected Sesame XML response');
27
+ }
28
+ const targets = [...xml.matchAll(/<Target(?:\s[^>]*)?>([\s\S]*?)<\/Target>/gi)];
29
+ for (const target of targets) {
30
+ const resolvers = [...target[1].matchAll(/<Resolver(?:\s([^>]*))?>([\s\S]*?)<\/Resolver>/gi)];
31
+ for (const resolver of resolvers) {
32
+ const raText = resolver[2].match(/<jradeg(?:\s[^>]*)?>\s*([^<]+)\s*<\/jradeg>/i)?.[1];
33
+ const decText = resolver[2].match(/<jdedeg(?:\s[^>]*)?>\s*([^<]+)\s*<\/jdedeg>/i)?.[1];
34
+ if (raText === undefined || decText === undefined)
35
+ continue;
36
+ const raDeg = Number(raText.trim());
37
+ const decDeg = Number(decText.trim());
38
+ if (!Number.isFinite(raDeg) || !Number.isFinite(decDeg) || raDeg < 0 || raDeg >= 360 || decDeg < -90 || decDeg > 90)
39
+ continue;
40
+ const resolverName = resolver[1]?.match(/\bname\s*=\s*["']([^"']+)["']/i)?.[1];
41
+ return { name, raDeg, decDeg, ...(resolverName ? { resolver: resolverName } : {}) };
42
+ }
43
+ }
44
+ return null;
45
+ }
@@ -0,0 +1,2 @@
1
+ export { SesameService, parseSesameXml } from './SesameService.js';
2
+ export type { NameResolver, ResolvedTarget, SesameServiceOptions } from './SesameService.js';
@@ -0,0 +1 @@
1
+ export { SesameService, parseSesameXml } from './SesameService.js';
@@ -0,0 +1,9 @@
1
+ import { MetadataColumn } from "astrospatial-core/metadata";
2
+ import type { TapColumn } from "./TapService.js";
3
+ /**
4
+ * Convert TAP_SCHEMA column descriptions into shared metadata columns.
5
+ *
6
+ * TAP-specific attributes remain available in MetadataColumn.details.
7
+ * This function does not parse or convert query result values.
8
+ */
9
+ export declare function tapColumnsToMetadata(columns: readonly TapColumn[]): MetadataColumn[];
@@ -0,0 +1,64 @@
1
+ import { ColumnType, MetadataColumn } from "astrospatial-core/metadata";
2
+ function columnType(column) {
3
+ const ucd = column.ucd.trim().toLowerCase();
4
+ const datatype = column.datatype.trim().toLowerCase();
5
+ const ucdTerms = ucd.split(";").map((term) => term.trim());
6
+ const primaryUcd = ucdTerms[0];
7
+ const arraysize = column.arraysize.trim();
8
+ const isScalar = arraysize === "" || arraysize === "1";
9
+ const numericTypes = new Set([
10
+ "short",
11
+ "int",
12
+ "integer",
13
+ "long",
14
+ "float",
15
+ "double",
16
+ "unsignedbyte",
17
+ ]);
18
+ const isNumericScalar = numericTypes.has(datatype) && isScalar;
19
+ if (primaryUcd === "pos.eq.ra" && isNumericScalar) {
20
+ return ColumnType.GEOM_RA;
21
+ }
22
+ if (primaryUcd === "pos.eq.dec" && isNumericScalar) {
23
+ return ColumnType.GEOM_DEC;
24
+ }
25
+ if (primaryUcd === "pos.outline") {
26
+ return ColumnType.GEOM_FOOTPRINT;
27
+ }
28
+ if (primaryUcd === "meta.id" && ucdTerms.includes("meta.main")) {
29
+ return ColumnType.MAIN_NAME;
30
+ }
31
+ return isNumericScalar ? ColumnType.NUMBER : ColumnType.STRING;
32
+ }
33
+ /**
34
+ * Convert TAP_SCHEMA column descriptions into shared metadata columns.
35
+ *
36
+ * TAP-specific attributes remain available in MetadataColumn.details.
37
+ * This function does not parse or convert query result values.
38
+ */
39
+ export function tapColumnsToMetadata(columns) {
40
+ return columns.map((column, index) => {
41
+ const details = new Map();
42
+ for (const key of [
43
+ "table_name",
44
+ "ucd",
45
+ "utype",
46
+ "datatype",
47
+ "arraysize",
48
+ "xtype",
49
+ "principal",
50
+ "indexed",
51
+ "std",
52
+ ]) {
53
+ details.set(key, column[key]);
54
+ }
55
+ return new MetadataColumn({
56
+ index,
57
+ name: column.column_name,
58
+ description: column.description,
59
+ unit: column.unit,
60
+ columnType: columnType(column),
61
+ details,
62
+ });
63
+ });
64
+ }
@@ -0,0 +1,80 @@
1
+ import { type HttpTransport } from "../../core/HttpTransport.js";
2
+ export type TapOutputFormat = "json" | "csv" | "votable" | (string & {});
3
+ export interface TapServiceOptions {
4
+ endpoint: string;
5
+ transport?: HttpTransport;
6
+ timeoutMs?: number;
7
+ }
8
+ export interface TapSyncQuery {
9
+ adql: string;
10
+ format?: TapOutputFormat;
11
+ maxrec?: number;
12
+ signal?: AbortSignal;
13
+ }
14
+ export interface TapQueryResponse {
15
+ body: string;
16
+ status: number;
17
+ headers: Headers;
18
+ contentType: string | null;
19
+ }
20
+ export interface TapSchema {
21
+ schema_name: string;
22
+ description: string;
23
+ utype: string;
24
+ }
25
+ export interface TapTable {
26
+ schema_name: string;
27
+ table_name: string;
28
+ table_type: string;
29
+ description: string;
30
+ utype: string;
31
+ }
32
+ export interface TapColumn {
33
+ table_name: string;
34
+ column_name: string;
35
+ description: string;
36
+ unit: string;
37
+ ucd: string;
38
+ utype: string;
39
+ datatype: string;
40
+ arraysize: string;
41
+ xtype: string;
42
+ principal: string;
43
+ indexed: string;
44
+ std: string;
45
+ }
46
+ export interface TapCsvTable {
47
+ columns: string[];
48
+ rows: Record<string, string>[];
49
+ }
50
+ /** Parse RFC-style CSV with quoted commas, escaped quotes and embedded newlines. */
51
+ export declare function parseTapCsvTable(csv: string): TapCsvTable;
52
+ /**
53
+ * Parse TAP CSV and return data rows.
54
+ * Preserves the existing public API.
55
+ */
56
+ export declare function parseTapCsv(csv: string): Record<string, string>[];
57
+ /** TAP 1.x synchronous ADQL client. No rendering or backend dependency. */
58
+ export declare class TapService {
59
+ private readonly baseUrl;
60
+ private readonly url;
61
+ private readonly transport;
62
+ private readonly timeoutMs;
63
+ constructor(options: TapServiceOptions);
64
+ /** Raw VOSI capabilities XML; parsing is intentionally left to the caller. */
65
+ getCapabilities(options?: {
66
+ signal?: AbortSignal;
67
+ }): Promise<string>;
68
+ private metadataRows;
69
+ listSchemas(options?: {
70
+ signal?: AbortSignal;
71
+ }): Promise<TapSchema[]>;
72
+ listTables(options?: {
73
+ schemaName?: string;
74
+ signal?: AbortSignal;
75
+ }): Promise<TapTable[]>;
76
+ listColumns(tableName: string, options?: {
77
+ signal?: AbortSignal;
78
+ }): Promise<TapColumn[]>;
79
+ querySync(query: TapSyncQuery): Promise<TapQueryResponse>;
80
+ }