@appstrate/afps-shared 0.4.0 → 0.5.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.
- package/package.json +2 -1
- package/src/archive-prefix.ts +81 -0
- package/src/mime.ts +46 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/afps-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Zero-dependency AFPS helpers shared by @appstrate/core and @appstrate/afps-runtime (companion-file checks, semver resolution, SRI integrity, credential templates, delivery.http projection)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -49,6 +49,7 @@
|
|
|
49
49
|
"./guarded-fetch": "./src/guarded-fetch.ts",
|
|
50
50
|
"./signed-token": "./src/signed-token.ts",
|
|
51
51
|
"./unzip-bounded": "./src/unzip-bounded.ts",
|
|
52
|
+
"./archive-prefix": "./src/archive-prefix.ts",
|
|
52
53
|
"./backoff": "./src/backoff.ts",
|
|
53
54
|
"./mime": "./src/mime.ts"
|
|
54
55
|
},
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// Copyright 2026 Appstrate
|
|
2
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Wrapper-folder stripping for AFPS archives — the ONE place that answers
|
|
6
|
+
* "did whoever zipped this wrap everything in a top-level directory, and if so
|
|
7
|
+
* what is it?".
|
|
8
|
+
*
|
|
9
|
+
* ZIPs created by macOS Finder or `zip -r folder/` wrap all entries under a
|
|
10
|
+
* single top-level directory, so a lookup like `files["manifest.json"]` misses.
|
|
11
|
+
* Only strip when EVERY entry shares one first-level prefix and none sits at
|
|
12
|
+
* the root; anything else is ambiguous and the collection comes back untouched,
|
|
13
|
+
* by identity.
|
|
14
|
+
*
|
|
15
|
+
* Lives in the zero-dependency shared package because two packages that cannot
|
|
16
|
+
* import each other both have to strip the same prefix the same way:
|
|
17
|
+
*
|
|
18
|
+
* - **Core** (`@appstrate/core/zip`) — the platform's package-ZIP parser, over
|
|
19
|
+
* fflate's `Record<string, Uint8Array>` shape, plus the sanitized `Map`
|
|
20
|
+
* shape the bundle path hands it.
|
|
21
|
+
* - **AFPS runtime** (`@appstrate/afps-runtime` → `bundle/archive-utils.ts`) —
|
|
22
|
+
* the `.afps` ingestion path, over the sanitized `Map` shape.
|
|
23
|
+
*
|
|
24
|
+
* The runtime used to keep a hand-copy of core's `Map` branch under a comment
|
|
25
|
+
* asking a human to "keep the two algorithms in sync", with no parity test to
|
|
26
|
+
* police it. That is exactly the arrangement `@appstrate/afps-shared/mime`
|
|
27
|
+
* replaced for the media-type set — where the policing parity test existed and
|
|
28
|
+
* the copies drifted three times anyway, once corrupting every OOXML download.
|
|
29
|
+
* `@appstrate/afps-runtime` deliberately carries no runtime dependency on core
|
|
30
|
+
* (it ships as a portable bundle runner and a standalone `afps` CLI), and
|
|
31
|
+
* afps-shared is a dependency of both, so one definition reaches both without
|
|
32
|
+
* either taking on the other.
|
|
33
|
+
*
|
|
34
|
+
* Change the rule HERE, not at a call site.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Detect and strip a single common wrapper folder from archive entries.
|
|
39
|
+
*
|
|
40
|
+
* Accepts either a `Record<string, Uint8Array>` (fflate's default ZIP shape) or
|
|
41
|
+
* a `Map<string, Uint8Array>` (the sanitized bundle shape) and returns the same
|
|
42
|
+
* type as the input — the ORIGINAL object, by identity, when there is nothing
|
|
43
|
+
* to strip.
|
|
44
|
+
*/
|
|
45
|
+
export function stripWrapperPrefix(files: Record<string, Uint8Array>): Record<string, Uint8Array>;
|
|
46
|
+
export function stripWrapperPrefix(files: Map<string, Uint8Array>): Map<string, Uint8Array>;
|
|
47
|
+
export function stripWrapperPrefix(
|
|
48
|
+
files: Record<string, Uint8Array> | Map<string, Uint8Array>,
|
|
49
|
+
): Record<string, Uint8Array> | Map<string, Uint8Array> {
|
|
50
|
+
const keys = files instanceof Map ? [...files.keys()] : Object.keys(files);
|
|
51
|
+
const prefix = commonWrapperPrefix(keys);
|
|
52
|
+
if (prefix === null) return files;
|
|
53
|
+
|
|
54
|
+
if (files instanceof Map) {
|
|
55
|
+
const stripped = new Map<string, Uint8Array>();
|
|
56
|
+
for (const [key, value] of files) stripped.set(key.slice(prefix.length), value);
|
|
57
|
+
return stripped;
|
|
58
|
+
}
|
|
59
|
+
const stripped: Record<string, Uint8Array> = {};
|
|
60
|
+
for (const [key, value] of Object.entries(files)) {
|
|
61
|
+
stripped[key.slice(prefix.length)] = value;
|
|
62
|
+
}
|
|
63
|
+
return stripped;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The single wrapper prefix (WITH its trailing slash) every key shares, or
|
|
68
|
+
* `null` when there is none to strip — no entries at all, an entry at the root
|
|
69
|
+
* level, or more than one top-level folder (ambiguous).
|
|
70
|
+
*/
|
|
71
|
+
function commonWrapperPrefix(keys: readonly string[]): string | null {
|
|
72
|
+
if (keys.length === 0) return null;
|
|
73
|
+
const prefixes = new Set<string>();
|
|
74
|
+
for (const key of keys) {
|
|
75
|
+
const slashIdx = key.indexOf("/");
|
|
76
|
+
if (slashIdx === -1) return null; // root-level file → no stripping
|
|
77
|
+
prefixes.add(key.slice(0, slashIdx));
|
|
78
|
+
}
|
|
79
|
+
if (prefixes.size !== 1) return null; // multiple top-level folders → ambiguous
|
|
80
|
+
return `${[...prefixes][0]}/`;
|
|
81
|
+
}
|
package/src/mime.ts
CHANGED
|
@@ -60,6 +60,24 @@ export function normalizeMime(mime: string | null | undefined): string {
|
|
|
60
60
|
* Media types whose payload is text, matched EXACTLY. A substring test would
|
|
61
61
|
* classify `application/vnd.openxmlformats-officedocument.spreadsheetml.sheet`
|
|
62
62
|
* as XML — see the module doc.
|
|
63
|
+
*
|
|
64
|
+
* Membership criterion, applied to every entry below:
|
|
65
|
+
*
|
|
66
|
+
* 1. the format's specification defines the payload as a character sequence,
|
|
67
|
+
* so a UTF-8 decode is lossless and a base64 round-trip is pure noise to
|
|
68
|
+
* the model;
|
|
69
|
+
* 2. it plausibly arrives as an HTTP response body (request-only grammars
|
|
70
|
+
* such as `application/sparql-query` or `application/jsonpath` are out);
|
|
71
|
+
* 3. it is not a container that may embed raw bytes. `application/rtf`
|
|
72
|
+
* (`\bin` blocks), `application/postscript` (binary-token PostScript) and
|
|
73
|
+
* `application/http` (a message whose body may be anything) all fail this
|
|
74
|
+
* and stay on the byte path even though their common form is ASCII.
|
|
75
|
+
*
|
|
76
|
+
* Completeness matters more than it used to. `isTextLikeMimeType` in
|
|
77
|
+
* `@appstrate/afps-runtime` used to treat any `; charset=…` parameter as a
|
|
78
|
+
* declaration of textness ahead of this set; it now consults that parameter
|
|
79
|
+
* only for an ambiguous base type, so a text format MISSING from this set is
|
|
80
|
+
* base64'd to the model even when the upstream declared a charset.
|
|
63
81
|
*/
|
|
64
82
|
export const TEXT_SHAPED_MEDIA_TYPES: ReadonlySet<string> = new Set([
|
|
65
83
|
// JSON family
|
|
@@ -68,22 +86,49 @@ export const TEXT_SHAPED_MEDIA_TYPES: ReadonlySet<string> = new Set([
|
|
|
68
86
|
"application/x-ndjson",
|
|
69
87
|
"application/jsonl",
|
|
70
88
|
"application/json-seq",
|
|
71
|
-
|
|
89
|
+
"application/json5",
|
|
90
|
+
"application/hjson",
|
|
91
|
+
// XML / SGML family
|
|
72
92
|
"application/xml",
|
|
73
93
|
"application/xml-dtd",
|
|
74
94
|
"application/xml-external-parsed-entity", // RFC 7303
|
|
95
|
+
"application/sgml",
|
|
75
96
|
"image/svg+xml", // XML-based, file-type never matches it
|
|
76
97
|
// YAML family
|
|
77
98
|
"application/yaml",
|
|
78
99
|
"application/x-yaml",
|
|
100
|
+
// TOML
|
|
101
|
+
"application/toml",
|
|
102
|
+
// Markdown — `text/markdown` is the registered spelling and already matches
|
|
103
|
+
// on the `text/` prefix; these two are the widespread unregistered ones.
|
|
104
|
+
"application/markdown",
|
|
105
|
+
"application/x-markdown",
|
|
106
|
+
// Query / schema languages served as source text
|
|
107
|
+
"application/sql", // RFC 6922
|
|
108
|
+
"application/graphql",
|
|
109
|
+
// RDF text serialisations. The `+json` / `+xml` RDF spellings already match
|
|
110
|
+
// on their structured suffix, and Turtle is registered as `text/turtle`.
|
|
111
|
+
"application/n-triples",
|
|
112
|
+
"application/n-quads",
|
|
113
|
+
"application/trig",
|
|
79
114
|
// Scripting / tabular / form encodings with no magic signature
|
|
80
115
|
"application/javascript",
|
|
81
116
|
"application/x-javascript",
|
|
82
117
|
"application/ecmascript",
|
|
118
|
+
"application/node",
|
|
119
|
+
"application/typescript",
|
|
120
|
+
"application/x-typescript",
|
|
121
|
+
"application/dart",
|
|
83
122
|
"application/csv",
|
|
84
123
|
"application/x-sh",
|
|
124
|
+
"application/x-shellscript",
|
|
85
125
|
"application/x-httpd-php",
|
|
86
126
|
"application/x-www-form-urlencoded",
|
|
127
|
+
// ASCII-armored / PEM blocks — base64 wrapped in text framing. Re-encoding
|
|
128
|
+
// them as base64 hides the framing the model needs to read.
|
|
129
|
+
"application/pem-certificate-chain", // RFC 8555
|
|
130
|
+
"application/pgp-keys", // RFC 3156 — the armored form
|
|
131
|
+
"application/pgp-signature", // RFC 3156 — the armored form
|
|
87
132
|
]);
|
|
88
133
|
|
|
89
134
|
/**
|