@assembly-js/app-foundation 0.3.10 → 0.4.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/CHANGELOG.md +7 -0
- package/bin/check-next-config.mjs +22 -3
- package/bin/check-reexchange-route.mjs +8 -7
- package/bin/check-route-reachability.mjs +55 -1
- package/bin/check-server-pdf-imports.mjs +3 -0
- package/dist/pdf.d.ts +7 -2
- package/dist/pdf.js +31 -3
- package/dist/proxy.js +4 -6
- package/dist/reexchange.js +2 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -19,6 +19,13 @@ A patch release never changes what an app's build requires; a minor release may
|
|
|
19
19
|
not by hand; see `changes/README.md`. CI fails a PR that changes a check script without a
|
|
20
20
|
`bump: minor` change file.
|
|
21
21
|
|
|
22
|
+
## 0.4.0
|
|
23
|
+
- [fix] The renewal response is never cached.
|
|
24
|
+
- [fix] Analytics identify the viewer again in migrated apps.
|
|
25
|
+
- [new] `extractPdfText` takes `maxPages` to cap how many pages are read.
|
|
26
|
+
- [action] An app without `app.config.ts` moves its Next options there on a platform update.
|
|
27
|
+
- [guidance] The checks' text changed: the PDF page cap, the renewal route wording, and route reachability on update turns.
|
|
28
|
+
|
|
22
29
|
## 0.3.10
|
|
23
30
|
- [guidance] Build agents are told to reuse an existing provider module and warned that adding a new requiredSecrets entry pauses a published app at the setup form.
|
|
24
31
|
|
|
@@ -12,17 +12,36 @@
|
|
|
12
12
|
// wrapper spreads. Prose rules get skipped; a failing build doesn't.
|
|
13
13
|
//
|
|
14
14
|
// An app without app.config.ts predates the wrapper split (cloned from an older
|
|
15
|
-
// foundation).
|
|
16
|
-
// exits 0 — those apps are protected by the boot/restart patch
|
|
15
|
+
// foundation). Off an update turn the wrapper form was never the contract, so
|
|
16
|
+
// this check exits 0 — those apps are protected by the boot/restart patch
|
|
17
|
+
// scripts instead. On a platform update turn (or --strict) it asks for the
|
|
18
|
+
// split, because those apps otherwise pass the build and fail acceptance.
|
|
17
19
|
import { readFileSync, existsSync } from 'node:fs';
|
|
18
20
|
import { join } from 'node:path';
|
|
19
21
|
|
|
20
22
|
const ROOT = process.cwd();
|
|
23
|
+
const STRICT = process.argv.includes('--strict') || process.env.ASSEMBLY_UPDATE_TURN === '1';
|
|
21
24
|
const CONFIG_FILES = ['next.config.ts', 'next.config.mjs', 'next.config.js'];
|
|
22
25
|
const APP_CONFIG_FILES = ['app.config.ts', 'app.config.mjs', 'app.config.js'];
|
|
23
26
|
|
|
24
27
|
if (!APP_CONFIG_FILES.some((f) => existsSync(join(ROOT, f)))) {
|
|
25
|
-
process.exit(0); // pre-wrapper app: not this check's contract
|
|
28
|
+
if (!STRICT) process.exit(0); // pre-wrapper app: not this check's contract
|
|
29
|
+
console.error(
|
|
30
|
+
'check-next-config: this app has no app.config.ts, so its Next options still live in next.config.*.\n\n' +
|
|
31
|
+
'Split them:\n' +
|
|
32
|
+
' 1. Create app.config.ts exporting the app\'s current Next options:\n\n' +
|
|
33
|
+
' import type { NextConfig } from "next";\n' +
|
|
34
|
+
' const appConfig: NextConfig = { /* the options from next.config */ };\n' +
|
|
35
|
+
' export default appConfig;\n\n' +
|
|
36
|
+
' Leave out allowedDevOrigins, devIndicators and experimental.reactDebugChannel: the platform\n' +
|
|
37
|
+
' injects those and withAssemblyDev applies them.\n' +
|
|
38
|
+
' 2. Replace next.config.ts with the platform wrapper:\n\n' +
|
|
39
|
+
' import { withAssemblyDev } from "@assembly-js/app-foundation/next";\n' +
|
|
40
|
+
' import appConfig from "./app.config";\n' +
|
|
41
|
+
' export default withAssemblyDev(appConfig);\n\n' +
|
|
42
|
+
' 3. Delete any other next.config.* file.\n',
|
|
43
|
+
);
|
|
44
|
+
process.exit(1);
|
|
26
45
|
}
|
|
27
46
|
|
|
28
47
|
const present = CONFIG_FILES.filter((f) => existsSync(join(ROOT, f)));
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Fails the build when the app's silent-renewal route is a hand-written copy
|
|
3
|
-
// instead of the package's handler. The
|
|
4
|
-
// (
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
3
|
+
// instead of the package's handler. The handler is the one copy the platform
|
|
4
|
+
// maintains (CSRF check, cookie clear-and-set, cookie probe, Safari body
|
|
5
|
+
// fallback); a hand-written copy may be missing any of them. Dependency-free
|
|
6
|
+
// on purpose: it runs inside the generated app's sandbox before any deps
|
|
7
|
+
// beyond Node exist.
|
|
8
8
|
//
|
|
9
9
|
// An app with no such route passes; check-proxy-csrf owns the missing-route
|
|
10
10
|
// finding.
|
|
@@ -40,8 +40,9 @@ out(
|
|
|
40
40
|
: `check-reexchange-route: ${ROUTE_FILE} is not the package handler (pre-existing; this build is not failed for it):`,
|
|
41
41
|
);
|
|
42
42
|
out('');
|
|
43
|
-
out('
|
|
44
|
-
out('
|
|
43
|
+
out('The package handler is the one copy the platform maintains (CSRF check, cookie clear-and-set,');
|
|
44
|
+
out('cookie probe, Safari body fallback). A hand-written copy may be missing any of those, so');
|
|
45
|
+
out('replace the whole file with exactly this:');
|
|
45
46
|
out('');
|
|
46
47
|
out(" import { createAssemblyReexchangeHandler } from '@assembly-js/app-foundation/server';");
|
|
47
48
|
out('');
|
|
@@ -9,10 +9,19 @@
|
|
|
9
9
|
// is statically evident string references — fully dynamic path construction
|
|
10
10
|
// is out of scope (the gate guards against pages the agent forgot to link,
|
|
11
11
|
// not obfuscation).
|
|
12
|
+
//
|
|
13
|
+
// On a platform update turn (ASSEMBLY_UPDATE_TURN=1) a page that was already
|
|
14
|
+
// unreferenced at ASSEMBLY_TURN_BASELINE_SHA only warns: an update never adds
|
|
15
|
+
// links or removes pages, so failing it would push the agent to delete code.
|
|
16
|
+
// A page added this turn, or orphaned this turn, still fails.
|
|
17
|
+
import { spawnSync } from 'node:child_process';
|
|
12
18
|
import { readFileSync, readdirSync, existsSync } from 'node:fs';
|
|
13
19
|
import { join, relative, sep } from 'node:path';
|
|
14
20
|
|
|
15
21
|
const ROOT = process.cwd();
|
|
22
|
+
const UPDATE_TURN = process.env.ASSEMBLY_UPDATE_TURN === '1';
|
|
23
|
+
const BASELINE = (process.env.ASSEMBLY_TURN_BASELINE_SHA ?? '').trim();
|
|
24
|
+
const SOURCE_FILE = /\.(tsx|ts|jsx|js|mjs)$/;
|
|
16
25
|
const APP_DIR = join(ROOT, 'app');
|
|
17
26
|
const MANIFEST = join(ROOT, '.assembly', 'app.json');
|
|
18
27
|
|
|
@@ -93,7 +102,37 @@ function referencePattern(route) {
|
|
|
93
102
|
return new RegExp(`['"\`]${escapeRe(prefix)}`);
|
|
94
103
|
}
|
|
95
104
|
|
|
105
|
+
function git(args) {
|
|
106
|
+
const result = spawnSync('git', args, { cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
107
|
+
return result.status === 0 ? result.stdout : null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Sources at the baseline commit, or null when there is no baseline or git
|
|
111
|
+
// cannot answer. Matched in JS rather than `git grep` so the JS reference
|
|
112
|
+
// regex needs no translation to another dialect.
|
|
113
|
+
function baselineSources() {
|
|
114
|
+
if (!BASELINE) return null;
|
|
115
|
+
const listing = git(['ls-tree', '-r', '--name-only', BASELINE, '--', 'app', 'components', 'lib']);
|
|
116
|
+
if (listing === null) return null;
|
|
117
|
+
const sources = new Map();
|
|
118
|
+
for (const path of listing.split('\n').filter((p) => SOURCE_FILE.test(p))) {
|
|
119
|
+
const content = git(['show', `${BASELINE}:${path}`]);
|
|
120
|
+
if (content === null) return null;
|
|
121
|
+
sources.set(path, content);
|
|
122
|
+
}
|
|
123
|
+
return sources;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// True only for a page that existed at the baseline and was already
|
|
127
|
+
// unreferenced there, so the agent did not cause the orphan.
|
|
128
|
+
function wasOrphanedAtBaseline(sources, path, pattern) {
|
|
129
|
+
if (!sources || !sources.has(path)) return false;
|
|
130
|
+
return ![...sources].some(([other, content]) => other !== path && pattern.test(content));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sources = UPDATE_TURN ? baselineSources() : null;
|
|
96
134
|
const violations = [];
|
|
135
|
+
const preExisting = [];
|
|
97
136
|
for (const file of pageFiles) {
|
|
98
137
|
const route = routeForPageFile(file);
|
|
99
138
|
if (allowed.has(route)) continue;
|
|
@@ -103,10 +142,25 @@ for (const file of pageFiles) {
|
|
|
103
142
|
(other) => other !== file && pattern.test(contents.get(other)),
|
|
104
143
|
);
|
|
105
144
|
if (!referenced) {
|
|
106
|
-
|
|
145
|
+
const rel = relative(ROOT, file).split(sep).join('/');
|
|
146
|
+
const entry = `${rel} (route ${route})`;
|
|
147
|
+
if (UPDATE_TURN && (!BASELINE || sources === null || wasOrphanedAtBaseline(sources, rel, pattern))) {
|
|
148
|
+
preExisting.push(entry);
|
|
149
|
+
} else {
|
|
150
|
+
violations.push(entry);
|
|
151
|
+
}
|
|
107
152
|
}
|
|
108
153
|
}
|
|
109
154
|
|
|
155
|
+
if (preExisting.length > 0) {
|
|
156
|
+
console.warn('');
|
|
157
|
+
console.warn(
|
|
158
|
+
'check-route-reachability: unreferenced pages (pre-existing; this build is not failed for it — a platform update never adds links or removes pages):',
|
|
159
|
+
);
|
|
160
|
+
for (const v of preExisting) console.warn(' - ' + v);
|
|
161
|
+
console.warn('');
|
|
162
|
+
}
|
|
163
|
+
|
|
110
164
|
if (violations.length > 0) {
|
|
111
165
|
console.error('');
|
|
112
166
|
console.error('Unreachable page routes — nothing in the app links to them:');
|
|
@@ -57,6 +57,9 @@ out("Replace each server-side use with the foundation's helper:");
|
|
|
57
57
|
out(" import { extractPdfText } from '@assembly-js/app-foundation/server';");
|
|
58
58
|
out(' const { text, pages } = await extractPdfText(bytes);');
|
|
59
59
|
out('');
|
|
60
|
+
out('Keep whatever page limit the old code applied by passing `{ maxPages: N }`, and keep any');
|
|
61
|
+
out('byte-size guard in front of the call.');
|
|
62
|
+
out('');
|
|
60
63
|
out('Then remove the pdf-parse / pdfjs-dist / @napi-rs/canvas dependencies, any');
|
|
61
64
|
out('GlobalWorkerOptions.workerSrc setup, and their entries in serverExternalPackages');
|
|
62
65
|
out('in app.config.ts. Do not touch client-side PDF rendering code.');
|
package/dist/pdf.d.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export interface PdfText {
|
|
2
|
-
/** Every page's text, pages separated by a newline. Empty when the PDF has no text layer (a scan). */
|
|
2
|
+
/** Every extracted page's text, pages separated by a newline. Empty when the PDF has no text layer (a scan). */
|
|
3
3
|
text: string;
|
|
4
|
+
/** The document's total page count, even when `maxPages` truncated the text. */
|
|
4
5
|
pages: number;
|
|
6
|
+
/** True when `maxPages` cut the extraction short of the document's last page. */
|
|
7
|
+
truncated: boolean;
|
|
5
8
|
}
|
|
6
|
-
export declare function extractPdfText(data: ArrayBuffer | Uint8Array
|
|
9
|
+
export declare function extractPdfText(data: ArrayBuffer | Uint8Array, opts?: {
|
|
10
|
+
maxPages?: number;
|
|
11
|
+
}): Promise<PdfText>;
|
package/dist/pdf.js
CHANGED
|
@@ -3,13 +3,41 @@
|
|
|
3
3
|
// runtime by paths computed from their own location, which the deploy tracer
|
|
4
4
|
// can't follow, so they pass in the sandbox and 500 once published. Loaded
|
|
5
5
|
// lazily so routes that never parse a PDF don't carry the bundle.
|
|
6
|
-
export async function extractPdfText(data) {
|
|
6
|
+
export async function extractPdfText(data, opts) {
|
|
7
7
|
const { getDocumentProxy, extractText } = await import('unpdf');
|
|
8
8
|
// pdf.js rejects a Node Buffer outright, so hand it a plain Uint8Array view.
|
|
9
9
|
const bytes = data instanceof ArrayBuffer
|
|
10
10
|
? new Uint8Array(data)
|
|
11
11
|
: new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
12
12
|
const pdf = await getDocumentProxy(bytes);
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
try {
|
|
14
|
+
const total = pdf.numPages;
|
|
15
|
+
const maxPages = opts?.maxPages;
|
|
16
|
+
if (maxPages === undefined || maxPages >= total) {
|
|
17
|
+
const { text, totalPages } = await extractText(pdf, { mergePages: true });
|
|
18
|
+
return { text, pages: totalPages, truncated: false };
|
|
19
|
+
}
|
|
20
|
+
const pageTexts = [];
|
|
21
|
+
for (let n = 1; n <= Math.max(0, maxPages); n++) {
|
|
22
|
+
const content = await (await pdf.getPage(n)).getTextContent();
|
|
23
|
+
pageTexts.push(content.items
|
|
24
|
+
.filter((item) => 'str' in item && item.str != null)
|
|
25
|
+
.map((item) => {
|
|
26
|
+
const { str, hasEOL } = item;
|
|
27
|
+
return str + (hasEOL ? '\n' : '');
|
|
28
|
+
})
|
|
29
|
+
.join(''));
|
|
30
|
+
}
|
|
31
|
+
// Mirrors unpdf's own normalisation so truncated and full output agree.
|
|
32
|
+
const text = pageTexts
|
|
33
|
+
.join('\n')
|
|
34
|
+
.replace(/[^\S\n]+/g, ' ')
|
|
35
|
+
.replace(/ ?\n ?/g, '\n')
|
|
36
|
+
.replace(/\n{3,}/g, '\n\n');
|
|
37
|
+
return { text, pages: total, truncated: true };
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
// Same teardown unpdf's own helpers use; the bundled proxy has no destroy().
|
|
41
|
+
await pdf.loadingTask.destroy();
|
|
42
|
+
}
|
|
15
43
|
}
|
package/dist/proxy.js
CHANGED
|
@@ -538,12 +538,10 @@ async function handleRequest(request, options) {
|
|
|
538
538
|
// isClient gates accent/font in layout SSR. Always forward it so layout can
|
|
539
539
|
// fall back when getSessionIdentity() cannot read the bearer on first paint.
|
|
540
540
|
requestHeaders.set('x-assembly-is-client', identity.clientId ? 'true' : 'false');
|
|
541
|
-
//
|
|
542
|
-
//
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
requestHeaders.set('x-assembly-workspace-id', identity.workspaceId ?? '');
|
|
546
|
-
}
|
|
541
|
+
// Set in both auth modes: migrated apps' layouts still read these for
|
|
542
|
+
// analytics identity, and inbound copies are stripped so they can't be forged.
|
|
543
|
+
requestHeaders.set('x-assembly-distinct-id', identity.clientId ?? '');
|
|
544
|
+
requestHeaders.set('x-assembly-workspace-id', identity.workspaceId ?? '');
|
|
547
545
|
}
|
|
548
546
|
// Hand the active bearer to the render as a request header. On first load the
|
|
549
547
|
// bearer exists nowhere else the render can see it (the Set-Cookie below isn't
|
package/dist/reexchange.js
CHANGED
|
@@ -41,6 +41,8 @@ export function createAssemblyReexchangeHandler() {
|
|
|
41
41
|
// script could read.
|
|
42
42
|
const wantsBearerInBody = req.headers.has(BEARER_IN_BODY_HEADER);
|
|
43
43
|
const response = NextResponse.json(wantsBearerInBody ? { ok: true, bearer: minted.bearer } : { ok: true });
|
|
44
|
+
// The body can carry a live bearer, so no cache may keep or replay it.
|
|
45
|
+
response.headers.set('Cache-Control', 'no-store');
|
|
44
46
|
const maxAge = Math.max(0, Math.floor((minted.bearerExpiresAt - Date.now()) / 1_000));
|
|
45
47
|
// headers.append, not response.cookies.set, which keys by name and would
|
|
46
48
|
// collapse the clear and the set of the same-named cookie. The legacy
|